1use std::collections::HashMap;
7use std::collections::HashSet;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use a3s_box_core::error::{BoxError, Result};
12use a3s_box_core::platform::Platform;
13
14use super::cache::{hash_context_sources, BuildCache};
15use super::dockerfile::{Dockerfile, Instruction, RunBindMount, RunCacheMount};
16use super::dockerignore::DockerIgnore;
17use super::layer::{sha256_bytes, sha256_file, LayerInfo};
18use crate::oci::image::OciImageConfig;
19use crate::oci::layers::extract_layer;
20use crate::oci::store::ImageStore;
21use crate::oci::{ImagePuller, RegistryAuth};
22
23mod handlers;
24mod stages;
25mod utils;
26
27#[cfg(test)]
28mod tests;
29
30use handlers::{
31 apply_base_config, execute_onbuild_trigger, handle_add, handle_copy, handle_run,
32 handle_run_with_pool, instruction_to_string,
33};
34use stages::{global_arg_decls, resolve_stage_rootfs, split_into_stages};
35use utils::{compute_diff_id, expand_args, format_size, resolve_path};
36
37#[derive(Debug, Clone)]
39pub struct BuildConfig {
40 pub context_dir: PathBuf,
42 pub dockerfile_path: PathBuf,
44 pub tag: Option<String>,
46 pub build_args: HashMap<String, String>,
48 pub quiet: bool,
50 pub platforms: Vec<Platform>,
53 pub target: Option<String>,
56 pub no_cache: bool,
58 pub metrics: Option<crate::prom::RuntimeMetrics>,
60 pub run_pool: Option<BuildRunPoolConfig>,
62}
63
64#[derive(Debug, Clone)]
66pub struct BuildRunPoolConfig {
67 pub socket: String,
69 pub image: Option<String>,
71 pub vcpus: u32,
73 pub memory_mb: u32,
75 pub guest_rootfs: String,
77 pub timeout_ns: u64,
79 pub run_cache_dir: PathBuf,
81}
82
83#[derive(Debug)]
85pub struct BuildResult {
86 pub reference: String,
88 pub digest: String,
90 pub size: u64,
92 pub layer_count: usize,
94}
95
96#[cfg_attr(not(feature = "pool"), allow(dead_code))]
97struct BuildRunPoolSession {
98 guest_rootfs: String,
99 timeout_ns: u64,
100 run_cache_dir: PathBuf,
101 #[cfg(feature = "pool")]
102 lease: crate::pool::PoolLeaseClient,
103}
104
105impl BuildRunPoolSession {
106 async fn acquire(config: &BuildRunPoolConfig, rootfs_dir: &Path) -> Result<Self> {
107 #[cfg(feature = "pool")]
108 {
109 let rootfs_dir = rootfs_dir.canonicalize().map_err(|e| {
110 BoxError::BuildError(format!(
111 "Failed to canonicalize build RUN rootfs {}: {}",
112 rootfs_dir.display(),
113 e
114 ))
115 })?;
116 let volume = format!("{}:{}:rw", rootfs_dir.display(), config.guest_rootfs);
117 let lease = crate::pool::PoolLeaseClient::acquire(crate::pool::PoolClientLease {
118 socket: config.socket.clone(),
119 image: config.image.clone(),
120 volumes: vec![volume],
121 vcpus: config.vcpus,
122 memory_mb: config.memory_mb,
123 })
124 .await
125 .map_err(|e| {
126 BoxError::BuildError(format!(
127 "Failed to lease warm-pool VM for Dockerfile RUN: {}",
128 e
129 ))
130 })?;
131 Ok(Self {
132 guest_rootfs: config.guest_rootfs.clone(),
133 timeout_ns: config.timeout_ns,
134 run_cache_dir: config.run_cache_dir.clone(),
135 lease,
136 })
137 }
138
139 #[cfg(not(feature = "pool"))]
140 {
141 let _ = (config, rootfs_dir);
142 Err(BoxError::BuildError(
143 "Dockerfile RUN warm-pool execution requires the runtime 'pool' feature"
144 .to_string(),
145 ))
146 }
147 }
148
149 async fn release(self) -> Result<()> {
150 #[cfg(feature = "pool")]
151 {
152 self.lease.release().await.map_err(|e| {
153 BoxError::BuildError(format!(
154 "Failed to release warm-pool Dockerfile RUN lease: {}",
155 e
156 ))
157 })
158 }
159
160 #[cfg(not(feature = "pool"))]
161 {
162 Ok(())
163 }
164 }
165}
166
167fn run_bind_mount_input_hash(
168 context_dir: &Path,
169 completed_stages: &[(Option<String>, PathBuf)],
170 bind_mounts: &[RunBindMount],
171) -> Option<String> {
172 let mut input = String::new();
173
174 for mount in bind_mounts {
175 if has_parent_component(&mount.source) {
176 return None;
177 }
178
179 let source = if mount.source.is_empty() {
180 "."
181 } else {
182 mount.source.as_str()
183 };
184 let (origin, source_root) = match mount.from.as_deref() {
185 Some(from_ref) => (
186 format!("stage:{from_ref}"),
187 resolve_stage_rootfs(from_ref, completed_stages).ok()?,
188 ),
189 None => ("context".to_string(), context_dir),
190 };
191
192 let source_hash = hash_context_sources(source_root, &[source.to_string()])?;
193 input.push_str(&origin);
194 input.push('\0');
195 input.push_str(source);
196 input.push('\0');
197 input.push_str(&source_hash);
198 input.push('\0');
199
200 if mount.from.is_none() {
201 let dockerignore = context_dir.join(".dockerignore");
202 if let Ok(bytes) = std::fs::read(&dockerignore) {
203 input.push_str(".dockerignore");
204 input.push('\0');
205 input.push_str(&sha256_bytes(&bytes));
206 input.push('\0');
207 }
208 }
209 }
210
211 Some(sha256_bytes(input.as_bytes()))
212}
213
214fn run_cache_mount_input_hash(
215 completed_stages: &[(Option<String>, PathBuf)],
216 cache_mounts: &[RunCacheMount],
217) -> Option<String> {
218 let mut input = String::new();
219 let mut saw_seeded_cache = false;
220
221 for mount in cache_mounts {
222 let Some(from_ref) = mount.from.as_deref() else {
223 continue;
224 };
225 if has_parent_component(&mount.source) {
226 return None;
227 }
228
229 saw_seeded_cache = true;
230 let source = if mount.source.is_empty() {
231 "."
232 } else {
233 mount.source.as_str()
234 };
235 let source_root = resolve_stage_rootfs(from_ref, completed_stages).ok()?;
236 let source_hash = hash_context_sources(source_root, &[source.to_string()])?;
237 input.push_str("cache-seed:");
238 input.push_str(from_ref);
239 input.push('\0');
240 input.push_str(source);
241 input.push('\0');
242 input.push_str(&source_hash);
243 input.push('\0');
244 }
245
246 saw_seeded_cache.then(|| sha256_bytes(input.as_bytes()))
247}
248
249fn run_mount_input_hash(
250 context_dir: &Path,
251 completed_stages: &[(Option<String>, PathBuf)],
252 cache_mounts: &[RunCacheMount],
253 bind_mounts: &[RunBindMount],
254) -> Option<String> {
255 let bind_hash = if bind_mounts.is_empty() {
256 None
257 } else {
258 run_bind_mount_input_hash(context_dir, completed_stages, bind_mounts)
259 };
260 let cache_hash = run_cache_mount_input_hash(completed_stages, cache_mounts);
261
262 match (bind_hash, cache_hash) {
263 (None, None) => None,
264 (Some(hash), None) | (None, Some(hash)) => Some(hash),
265 (Some(bind_hash), Some(cache_hash)) => Some(sha256_bytes(
266 format!("bind\0{bind_hash}\0cache\0{cache_hash}").as_bytes(),
267 )),
268 }
269}
270
271fn has_parent_component(path: &str) -> bool {
272 Path::new(path)
273 .components()
274 .any(|component| matches!(component, std::path::Component::ParentDir))
275}
276
277async fn resolve_run_mount_source_roots(
278 completed_stages: &[(Option<String>, PathBuf)],
279 bind_mounts: &[RunBindMount],
280 cache_mounts: &[RunCacheMount],
281 store: &Arc<ImageStore>,
282 build_dir: &Path,
283 external_from_rootfs: &mut HashMap<String, PathBuf>,
284) -> Result<Option<Vec<(Option<String>, PathBuf)>>> {
285 let mut roots: Option<Vec<(Option<String>, PathBuf)>> = None;
286 let mut external_refs = HashSet::new();
287
288 let mut from_refs: Vec<&str> = Vec::new();
289 from_refs.extend(bind_mounts.iter().filter_map(|mount| mount.from.as_deref()));
290 from_refs.extend(
291 cache_mounts
292 .iter()
293 .filter_map(|mount| mount.from.as_deref()),
294 );
295
296 for from_ref in from_refs {
297 if resolve_stage_rootfs(from_ref, completed_stages).is_ok()
298 || roots
299 .as_deref()
300 .is_some_and(|resolved| resolve_stage_rootfs(from_ref, resolved).is_ok())
301 {
302 continue;
303 }
304
305 if !external_refs.insert(from_ref.to_string()) {
306 continue;
307 }
308
309 let rootfs = resolve_external_from_rootfs(
310 from_ref,
311 "RUN bind mount",
312 store,
313 build_dir,
314 external_from_rootfs,
315 )
316 .await?;
317 roots
318 .get_or_insert_with(|| completed_stages.to_vec())
319 .push((Some(from_ref.to_string()), rootfs));
320 }
321
322 Ok(roots)
323}
324
325pub(super) struct BuildState {
327 pub(super) workdir: String,
329 pub(super) env: Vec<(String, String)>,
331 pub(super) entrypoint: Option<Vec<String>>,
333 pub(super) cmd: Option<Vec<String>>,
335 pub(super) user: Option<String>,
337 pub(super) exposed_ports: Vec<String>,
339 pub(super) labels: HashMap<String, String>,
341 pub(super) layers: Vec<LayerInfo>,
343 pub(super) diff_ids: Vec<String>,
345 pub(super) history: Vec<HistoryEntry>,
347 pub(super) build_args: HashMap<String, String>,
350 pub(super) declared_args: HashSet<String>,
354 pub(super) shell: Vec<String>,
356 pub(super) stop_signal: Option<String>,
358 pub(super) health_check: Option<OciHealthCheck>,
360 pub(super) onbuild: Vec<String>,
362 pub(super) volumes: Vec<String>,
364}
365
366#[derive(Debug, Clone)]
368pub(super) struct HistoryEntry {
369 pub(super) created_by: String,
370 pub(super) empty_layer: bool,
371}
372
373pub use crate::oci::image::OciHealthCheck;
374
375impl BuildState {
376 fn new(build_args: HashMap<String, String>) -> Self {
377 Self {
378 workdir: "/".to_string(),
379 env: Vec::new(),
380 entrypoint: None,
381 cmd: None,
382 user: None,
383 exposed_ports: Vec::new(),
384 labels: HashMap::new(),
385 layers: Vec::new(),
386 diff_ids: Vec::new(),
387 history: Vec::new(),
388 build_args,
389 declared_args: HashSet::new(),
390 shell: vec!["/bin/sh".to_string(), "-c".to_string()],
391 stop_signal: None,
392 health_check: None,
393 onbuild: Vec::new(),
394 volumes: Vec::new(),
395 }
396 }
397
398 fn declared_build_args(&self) -> HashMap<String, String> {
401 self.build_args
402 .iter()
403 .filter(|(name, _)| self.declared_args.contains(*name))
404 .map(|(name, value)| (name.clone(), value.clone()))
405 .collect()
406 }
407
408 fn expansion_vars(&self) -> HashMap<String, String> {
412 let mut vars = self.declared_build_args();
413 for (key, value) in &self.env {
414 vars.insert(key.clone(), value.clone());
415 }
416 vars
417 }
418
419 fn run_env(&self) -> Vec<(String, String)> {
423 let mut vars = self.declared_build_args();
424 for (key, value) in &self.env {
425 vars.insert(key.clone(), value.clone());
426 }
427 let mut pairs = vars.into_iter().collect::<Vec<_>>();
428 pairs.sort_by(|a, b| a.0.cmp(&b.0));
429 pairs
430 }
431
432 fn seed_global_arg(&mut self, name: &str, default: Option<&str>) {
435 self.declared_args.insert(name.to_string());
436 if !self.build_args.contains_key(name) {
437 if let Some(val) = default {
438 self.build_args.insert(name.to_string(), val.to_string());
439 }
440 }
441 }
442}
443
444pub async fn build(config: BuildConfig, store: Arc<ImageStore>) -> Result<BuildResult> {
459 validate_build_config(&config)?;
460
461 let dockerfile = Dockerfile::from_file(&config.dockerfile_path)?;
463
464 let dockerignore = DockerIgnore::load(&config.context_dir);
466
467 if !config.quiet {
468 println!("Building from {}", config.dockerfile_path.display());
469 if !dockerignore.is_empty() {
470 println!("Using .dockerignore");
471 }
472 }
473
474 let stages = split_into_stages(&dockerfile.instructions);
476 let global_args = global_arg_decls(&dockerfile.instructions);
480 let total_stages = stages.len();
481
482 let output_stage_idx = match config.target.as_deref() {
486 Some(target) => stages
487 .iter()
488 .position(|s| s.alias.as_deref() == Some(target))
489 .or_else(|| target.parse::<usize>().ok().filter(|i| *i < total_stages))
490 .ok_or_else(|| {
491 BoxError::BuildError(format!("target build stage '{}' not found", target))
492 })?,
493 None => total_stages - 1,
494 };
495
496 let mut completed_stages: Vec<(Option<String>, PathBuf)> = Vec::new();
498 let mut external_from_rootfs: HashMap<String, PathBuf> = HashMap::new();
501
502 let build_dir = tempfile::TempDir::new()
504 .map_err(|e| BoxError::BuildError(format!("Failed to create build directory: {}", e)))?;
505
506 let mut final_state = BuildState::new(config.build_args.clone());
507 let mut final_base_layers: Vec<LayerInfo> = Vec::new();
508 let mut final_base_diff_ids: Vec<String> = Vec::new();
509
510 let total_instructions = dockerfile.instructions.len();
511 let mut global_step = 0;
512
513 for (stage_idx, stage) in stages.iter().enumerate() {
514 let is_final_stage = stage_idx == output_stage_idx;
515
516 let rootfs_dir = build_dir.path().join(format!("rootfs_{}", stage_idx));
517 let layers_dir = build_dir.path().join(format!("layers_{}", stage_idx));
518 std::fs::create_dir_all(&rootfs_dir).map_err(|e| {
519 BoxError::BuildError(format!("Failed to create rootfs directory: {}", e))
520 })?;
521 std::fs::create_dir_all(&layers_dir).map_err(|e| {
522 BoxError::BuildError(format!("Failed to create layers directory: {}", e))
523 })?;
524
525 let mut state = BuildState::new(config.build_args.clone());
526 if stage_idx > 0 {
530 for (name, default) in &global_args {
531 state.seed_global_arg(name, default.as_deref());
532 }
533 }
534 let mut base_layers: Vec<LayerInfo> = Vec::new();
535 let mut base_diff_ids: Vec<String> = Vec::new();
536 let mut run_pool_session: Option<BuildRunPoolSession> = None;
537
538 let cache = if config.no_cache {
540 None
541 } else {
542 BuildCache::open()
543 };
544 let mut chain_key = String::new();
546 let mut cache_valid = true;
548
549 for instruction in &stage.instructions {
550 global_step += 1;
551 let step = global_step;
552 let run_mount_source_roots = if let Instruction::Run {
553 bind_mounts,
554 cache_mounts,
555 ..
556 } = instruction
557 {
558 resolve_run_mount_source_roots(
559 &completed_stages,
560 bind_mounts,
561 cache_mounts,
562 &store,
563 build_dir.path(),
564 &mut external_from_rootfs,
565 )
566 .await?
567 } else {
568 None
569 };
570
571 if !matches!(instruction, Instruction::From { .. }) {
576 let repr = match instruction {
582 Instruction::Env { vars } => {
583 let pairs: Vec<String> = vars
584 .iter()
585 .map(|(k, v)| {
586 format!("{}={}", k, expand_args(v, &state.expansion_vars()))
587 })
588 .collect();
589 format!("ENV {}", pairs.join(" "))
590 }
591 Instruction::Arg { name, default } => {
592 let effective = state
593 .build_args
594 .get(name)
595 .cloned()
596 .or_else(|| default.clone())
597 .unwrap_or_default();
598 format!("ARG {}={}", name, effective)
599 }
600 other => instruction_to_string(other),
601 };
602 let input_hash = match instruction {
603 Instruction::Copy {
604 src, from: None, ..
605 } => hash_context_sources(&config.context_dir, src),
606 Instruction::Copy {
607 src,
608 from: Some(from_ref),
609 ..
610 } => {
611 resolve_stage_rootfs(from_ref, &completed_stages)
619 .ok()
620 .and_then(|rootfs| hash_context_sources(rootfs, src))
621 }
622 Instruction::Add { src, .. } => hash_context_sources(&config.context_dir, src),
623 Instruction::Run {
624 cache_mounts,
625 bind_mounts,
626 ..
627 } => run_mount_input_hash(
628 &config.context_dir,
629 run_mount_source_roots
630 .as_deref()
631 .unwrap_or(&completed_stages),
632 cache_mounts,
633 bind_mounts,
634 ),
635 _ => None,
636 };
637 chain_key = BuildCache::chain(&chain_key, &repr, input_hash.as_deref());
638 }
639
640 match instruction {
641 Instruction::From { image, alias } => {
642 if !config.quiet {
643 if total_stages > 1 {
644 println!(
645 "Step {}/{}: FROM {} (stage {}/{}{})",
646 step,
647 total_instructions,
648 image,
649 stage_idx + 1,
650 total_stages,
651 alias
652 .as_ref()
653 .map(|a| format!(" as {}", a))
654 .unwrap_or_default()
655 );
656 } else {
657 println!("Step {}/{}: FROM {}", step, total_instructions, image);
658 }
659 }
660 let (layers, diff_ids, base_config) = handle_from(
661 image,
662 &rootfs_dir,
663 &layers_dir,
664 &store,
665 &state.declared_build_args(),
666 )
667 .await?;
668 base_layers = layers;
669 base_diff_ids = diff_ids;
670
671 chain_key = sha256_bytes(base_diff_ids.join(",").as_bytes());
675 cache_valid = true;
676
677 apply_base_config(&mut state, &base_config);
679
680 if !base_config.onbuild.is_empty() && !config.quiet {
682 println!(
683 " Executing {} ONBUILD trigger(s) from base image",
684 base_config.onbuild.len()
685 );
686 }
687 for trigger in &base_config.onbuild {
688 execute_onbuild_trigger(
689 trigger,
690 &mut state,
691 &config,
692 &rootfs_dir,
693 &layers_dir,
694 &base_layers,
695 &completed_stages,
696 )?;
697 }
698
699 state.history.push(HistoryEntry {
700 created_by: format!("FROM {}", image),
701 empty_layer: true,
702 });
703 }
704
705 Instruction::Copy {
706 src,
707 dst,
708 from,
709 chown,
710 } => {
711 let created_by = if let Some(from_ref) = from {
712 format!("COPY --from={} {} {}", from_ref, src.join(" "), dst)
713 } else if let Some(owner) = chown {
714 format!("COPY --chown={} {} {}", owner, src.join(" "), dst)
715 } else {
716 format!("COPY {} {}", src.join(" "), dst)
717 };
718 if try_reuse_cached_layer(
719 CachedLayerReuse {
720 cache_valid,
721 cache: cache.as_ref(),
722 chain_key: &chain_key,
723 rootfs_dir: &rootfs_dir,
724 layers_dir: &layers_dir,
725 layer_index: state.layers.len() + base_layers.len(),
726 created_by: &created_by,
727 },
728 &mut state,
729 )?
730 .is_some()
731 {
732 if !config.quiet {
733 println!(
734 "Step {}/{}: {} (CACHED)",
735 step, total_instructions, created_by
736 );
737 }
738 continue;
739 }
740 cache_valid = false;
741
742 if let Some(from_ref) = from {
743 if !config.quiet {
744 println!(
745 "Step {}/{}: COPY --from={} {} {}",
746 step,
747 total_instructions,
748 from_ref,
749 src.join(" "),
750 dst
751 );
752 }
753 let from_rootfs: PathBuf =
757 match resolve_stage_rootfs(from_ref, &completed_stages) {
758 Ok(stage_rootfs) => stage_rootfs.to_path_buf(),
759 Err(_) => {
760 resolve_external_from_rootfs(
761 from_ref,
762 "COPY --from",
763 &store,
764 build_dir.path(),
765 &mut external_from_rootfs,
766 )
767 .await?
768 }
769 };
770 let layer_info = handle_copy(
773 src,
774 dst,
775 chown.as_deref(),
776 &from_rootfs,
777 &rootfs_dir,
778 &layers_dir,
779 &state.workdir,
780 state.layers.len() + base_layers.len(),
781 None,
782 )?;
783 let diff_id = compute_diff_id(&layer_info.path)?;
784 if let Some(c) = &cache {
785 c.store(&chain_key, &layer_info, &diff_id);
786 }
787 state.diff_ids.push(diff_id);
788 state.layers.push(layer_info);
789 state.history.push(HistoryEntry {
790 created_by: format!(
791 "COPY --from={} {} {}",
792 from_ref,
793 src.join(" "),
794 dst
795 ),
796 empty_layer: false,
797 });
798 } else {
799 if !config.quiet {
800 println!(
801 "Step {}/{}: COPY {} {}",
802 step,
803 total_instructions,
804 src.join(" "),
805 dst
806 );
807 }
808 let layer_info = handle_copy(
809 src,
810 dst,
811 chown.as_deref(),
812 &config.context_dir,
813 &rootfs_dir,
814 &layers_dir,
815 &state.workdir,
816 state.layers.len() + base_layers.len(),
817 Some(&dockerignore),
818 )?;
819 let diff_id = compute_diff_id(&layer_info.path)?;
820 if let Some(c) = &cache {
821 c.store(&chain_key, &layer_info, &diff_id);
822 }
823 state.diff_ids.push(diff_id);
824 state.layers.push(layer_info);
825 state.history.push(HistoryEntry {
826 created_by: format!("COPY {} {}", src.join(" "), dst),
827 empty_layer: false,
828 });
829 }
830 }
831
832 Instruction::Add { src, dst, chown } => {
833 let created_by = format!("ADD {} {}", src.join(" "), dst);
834 if try_reuse_cached_layer(
835 CachedLayerReuse {
836 cache_valid,
837 cache: cache.as_ref(),
838 chain_key: &chain_key,
839 rootfs_dir: &rootfs_dir,
840 layers_dir: &layers_dir,
841 layer_index: state.layers.len() + base_layers.len(),
842 created_by: &created_by,
843 },
844 &mut state,
845 )?
846 .is_some()
847 {
848 if !config.quiet {
849 println!(
850 "Step {}/{}: {} (CACHED)",
851 step, total_instructions, created_by
852 );
853 }
854 continue;
855 }
856 cache_valid = false;
857
858 if !config.quiet {
859 println!(
860 "Step {}/{}: ADD {} {}",
861 step,
862 total_instructions,
863 src.join(" "),
864 dst
865 );
866 }
867 let layer_info = handle_add(
868 src,
869 dst,
870 chown.as_deref(),
871 &config.context_dir,
872 &rootfs_dir,
873 &layers_dir,
874 &state.workdir,
875 state.layers.len() + base_layers.len(),
876 Some(&dockerignore),
877 )?;
878 let diff_id = compute_diff_id(&layer_info.path)?;
879 if let Some(c) = &cache {
880 c.store(&chain_key, &layer_info, &diff_id);
881 }
882 state.diff_ids.push(diff_id);
883 state.layers.push(layer_info);
884 state.history.push(HistoryEntry {
885 created_by: format!("ADD {} {}", src.join(" "), dst),
886 empty_layer: false,
887 });
888 }
889
890 Instruction::Run {
891 command,
892 cache_mounts,
893 bind_mounts,
894 tmpfs_mounts,
895 } => {
896 let created_by = instruction_to_string(instruction);
897 if try_reuse_cached_layer(
898 CachedLayerReuse {
899 cache_valid,
900 cache: cache.as_ref(),
901 chain_key: &chain_key,
902 rootfs_dir: &rootfs_dir,
903 layers_dir: &layers_dir,
904 layer_index: state.layers.len() + base_layers.len(),
905 created_by: &created_by,
906 },
907 &mut state,
908 )?
909 .is_some()
910 {
911 if !config.quiet {
912 println!(
913 "Step {}/{}: {} (CACHED)",
914 step, total_instructions, created_by
915 );
916 }
917 continue;
918 }
919 cache_valid = false;
920
921 if !config.quiet {
922 println!("Step {}/{}: {}", step, total_instructions, created_by);
923 }
924 let layer_opt = if let Some(pool_config) = &config.run_pool {
925 if run_pool_session.is_none() {
926 run_pool_session =
927 Some(BuildRunPoolSession::acquire(pool_config, &rootfs_dir).await?);
928 }
929 let session = run_pool_session
930 .as_ref()
931 .expect("run pool session was just initialized");
932 handle_run_with_pool(
933 command,
934 cache_mounts,
935 bind_mounts,
936 tmpfs_mounts,
937 &config.context_dir,
938 run_mount_source_roots
939 .as_deref()
940 .unwrap_or(&completed_stages),
941 &rootfs_dir,
942 &layers_dir,
943 &state.workdir,
944 &state.run_env(),
945 &state.shell,
946 state.user.as_deref(),
947 state.layers.len() + base_layers.len(),
948 config.quiet,
949 session,
950 Some(&dockerignore),
951 )
952 .await?
953 } else {
954 handle_run(
955 command,
956 cache_mounts,
957 bind_mounts,
958 tmpfs_mounts,
959 &config.context_dir,
960 run_mount_source_roots
961 .as_deref()
962 .unwrap_or(&completed_stages),
963 &rootfs_dir,
964 &layers_dir,
965 &state.workdir,
966 &state.run_env(),
967 &state.shell,
968 state.layers.len() + base_layers.len(),
969 config.quiet,
970 Some(&dockerignore),
971 )?
972 };
973 if let Some(layer_info) = layer_opt {
974 let diff_id = compute_diff_id(&layer_info.path)?;
975 if let Some(c) = &cache {
976 c.store(&chain_key, &layer_info, &diff_id);
977 }
978 state.diff_ids.push(diff_id);
979 state.layers.push(layer_info);
980 state.history.push(HistoryEntry {
981 created_by: created_by.clone(),
982 empty_layer: false,
983 });
984 } else {
985 state.history.push(HistoryEntry {
986 created_by: created_by.clone(),
987 empty_layer: true,
988 });
989 }
990 }
991
992 Instruction::Workdir { path } => {
993 if !config.quiet {
994 println!("Step {}/{}: WORKDIR {}", step, total_instructions, path);
995 }
996 let expanded_path = expand_args(path, &state.expansion_vars());
998 state.workdir = resolve_path(&state.workdir, &expanded_path);
999 let full = rootfs_dir.join(state.workdir.trim_start_matches('/'));
1000 let _ = std::fs::create_dir_all(&full);
1001 state.history.push(HistoryEntry {
1002 created_by: format!("WORKDIR {}", path),
1003 empty_layer: true,
1004 });
1005 }
1006
1007 Instruction::Env { vars } => {
1008 let display: Vec<String> =
1009 vars.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
1010 let display = display.join(" ");
1011 if !config.quiet {
1012 println!("Step {}/{}: ENV {}", step, total_instructions, display);
1013 }
1014 for (key, value) in vars {
1015 let expanded_value = expand_args(value, &state.expansion_vars());
1018 if let Some(existing) = state.env.iter_mut().find(|(k, _)| k == key) {
1019 existing.1 = expanded_value;
1020 } else {
1021 state.env.push((key.clone(), expanded_value));
1022 }
1023 }
1024 state.history.push(HistoryEntry {
1025 created_by: format!("ENV {}", display),
1026 empty_layer: true,
1027 });
1028 }
1029
1030 Instruction::Entrypoint { exec } => {
1031 if !config.quiet {
1032 println!(
1033 "Step {}/{}: ENTRYPOINT {:?}",
1034 step, total_instructions, exec
1035 );
1036 }
1037 state.entrypoint = Some(exec.clone());
1038 state.history.push(HistoryEntry {
1039 created_by: format!("ENTRYPOINT {:?}", exec),
1040 empty_layer: true,
1041 });
1042 }
1043
1044 Instruction::Cmd { exec } => {
1045 if !config.quiet {
1046 println!("Step {}/{}: CMD {:?}", step, total_instructions, exec);
1047 }
1048 state.cmd = Some(exec.clone());
1049 state.history.push(HistoryEntry {
1050 created_by: format!("CMD {:?}", exec),
1051 empty_layer: true,
1052 });
1053 }
1054
1055 Instruction::Expose { ports } => {
1056 let joined = ports.join(" ");
1057 if !config.quiet {
1058 println!("Step {}/{}: EXPOSE {}", step, total_instructions, joined);
1059 }
1060 for port in ports {
1061 if !state.exposed_ports.contains(port) {
1062 state.exposed_ports.push(port.clone());
1063 }
1064 }
1065 state.history.push(HistoryEntry {
1066 created_by: format!("EXPOSE {}", joined),
1067 empty_layer: true,
1068 });
1069 }
1070
1071 Instruction::Label { pairs } => {
1072 let joined = pairs
1073 .iter()
1074 .map(|(k, v)| format!("{}={}", k, v))
1075 .collect::<Vec<_>>()
1076 .join(" ");
1077 if !config.quiet {
1078 println!("Step {}/{}: LABEL {}", step, total_instructions, joined);
1079 }
1080 for (key, value) in pairs {
1081 state.labels.insert(key.clone(), value.clone());
1082 }
1083 state.history.push(HistoryEntry {
1084 created_by: format!("LABEL {}", joined),
1085 empty_layer: true,
1086 });
1087 }
1088
1089 Instruction::User { user } => {
1090 if !config.quiet {
1091 println!("Step {}/{}: USER {}", step, total_instructions, user);
1092 }
1093 state.user = Some(user.clone());
1094 state.history.push(HistoryEntry {
1095 created_by: format!("USER {}", user),
1096 empty_layer: true,
1097 });
1098 }
1099
1100 Instruction::Arg { name, default } => {
1101 if !config.quiet {
1102 println!("Step {}/{}: ARG {}", step, total_instructions, name);
1103 }
1104 state.declared_args.insert(name.clone());
1105 if !state.build_args.contains_key(name) {
1106 if let Some(val) = default {
1107 state.build_args.insert(name.clone(), val.clone());
1108 }
1109 }
1110 state.history.push(HistoryEntry {
1111 created_by: format!("ARG {}", name),
1112 empty_layer: true,
1113 });
1114 }
1115
1116 Instruction::Shell { exec } => {
1117 if !config.quiet {
1118 println!("Step {}/{}: SHELL {:?}", step, total_instructions, exec);
1119 }
1120 state.shell = exec.clone();
1121 state.history.push(HistoryEntry {
1122 created_by: format!("SHELL {:?}", exec),
1123 empty_layer: true,
1124 });
1125 }
1126
1127 Instruction::StopSignal { signal } => {
1128 if !config.quiet {
1129 println!(
1130 "Step {}/{}: STOPSIGNAL {}",
1131 step, total_instructions, signal
1132 );
1133 }
1134 state.stop_signal = Some(signal.clone());
1135 state.history.push(HistoryEntry {
1136 created_by: format!("STOPSIGNAL {}", signal),
1137 empty_layer: true,
1138 });
1139 }
1140
1141 Instruction::HealthCheck {
1142 cmd,
1143 interval,
1144 timeout,
1145 retries,
1146 start_period,
1147 } => {
1148 if !config.quiet {
1149 if cmd.is_some() {
1150 println!("Step {}/{}: HEALTHCHECK CMD ...", step, total_instructions);
1151 } else {
1152 println!("Step {}/{}: HEALTHCHECK NONE", step, total_instructions);
1153 }
1154 }
1155 state.health_check = cmd.as_ref().map(|c| OciHealthCheck {
1156 test: c.clone(),
1157 interval: *interval,
1158 timeout: *timeout,
1159 retries: *retries,
1160 start_period: *start_period,
1161 });
1162 state.history.push(HistoryEntry {
1163 created_by: if cmd.is_some() {
1164 "HEALTHCHECK CMD ...".to_string()
1165 } else {
1166 "HEALTHCHECK NONE".to_string()
1167 },
1168 empty_layer: true,
1169 });
1170 }
1171
1172 Instruction::OnBuild { instruction } => {
1173 let trigger = format!("{:?}", instruction);
1174 if !config.quiet {
1175 println!("Step {}/{}: ONBUILD {}", step, total_instructions, trigger);
1176 }
1177 state.onbuild.push(instruction_to_string(instruction));
1179 state.history.push(HistoryEntry {
1180 created_by: format!("ONBUILD {}", instruction_to_string(instruction)),
1181 empty_layer: true,
1182 });
1183 }
1184
1185 Instruction::Volume { paths } => {
1186 if !config.quiet {
1187 println!(
1188 "Step {}/{}: VOLUME {}",
1189 step,
1190 total_instructions,
1191 paths.join(" ")
1192 );
1193 }
1194 for p in paths {
1195 if !state.volumes.contains(p) {
1196 state.volumes.push(p.clone());
1197 }
1198 }
1199 for p in paths {
1201 let full = rootfs_dir.join(p.trim_start_matches('/'));
1202 let _ = std::fs::create_dir_all(&full);
1203 }
1204 state.history.push(HistoryEntry {
1205 created_by: format!("VOLUME {}", paths.join(" ")),
1206 empty_layer: true,
1207 });
1208 }
1209 }
1210 }
1211
1212 if let Some(session) = run_pool_session.take() {
1213 session.release().await?;
1214 }
1215
1216 completed_stages.push((stage.alias.clone(), rootfs_dir.clone()));
1218
1219 if is_final_stage {
1220 final_state = state;
1221 final_base_layers = base_layers;
1222 final_base_diff_ids = base_diff_ids;
1223 break;
1225 }
1226 }
1227
1228 let reference = config
1230 .tag
1231 .clone()
1232 .unwrap_or_else(|| "a3s-build:latest".to_string());
1233
1234 let final_layers_dir = build_dir
1235 .path()
1236 .join(format!("layers_{}", output_stage_idx));
1237
1238 let target_platform = config
1240 .platforms
1241 .first()
1242 .cloned()
1243 .unwrap_or_else(default_target_platform);
1244
1245 let result = assemble_image(
1246 &reference,
1247 &final_state,
1248 &final_base_layers,
1249 &final_base_diff_ids,
1250 &final_layers_dir,
1251 &store,
1252 &target_platform,
1253 )
1254 .await?;
1255
1256 if !config.quiet {
1257 println!(
1258 "Successfully built {} ({} layers, {}, {})",
1259 reference,
1260 result.layer_count,
1261 format_size(result.size),
1262 target_platform,
1263 );
1264 }
1265
1266 if let Some(ref m) = config.metrics {
1267 m.image_build_total.inc();
1268 }
1269
1270 Ok(result)
1271}
1272
1273struct CachedLayerReuse<'a> {
1285 cache_valid: bool,
1286 cache: Option<&'a BuildCache>,
1287 chain_key: &'a str,
1288 rootfs_dir: &'a Path,
1289 layers_dir: &'a Path,
1290 layer_index: usize,
1291 created_by: &'a str,
1292}
1293
1294fn try_reuse_cached_layer(
1295 request: CachedLayerReuse<'_>,
1296 state: &mut BuildState,
1297) -> Result<Option<()>> {
1298 if !request.cache_valid {
1299 return Ok(None);
1300 }
1301 let Some(cached) = request.cache.and_then(|c| c.lookup(request.chain_key)) else {
1302 return Ok(None);
1303 };
1304
1305 let local_layer = request.layers_dir.join(format!(
1306 "cached_{}_{}.tar.gz",
1307 request.layer_index, cached.digest
1308 ));
1309 if let Err(error) = std::fs::copy(&cached.blob_path, &local_layer) {
1310 tracing::warn!(
1311 key = %request.chain_key,
1312 source = %cached.blob_path.display(),
1313 error = %error,
1314 "Build cache blob disappeared before it could be materialized; rebuilding instruction"
1315 );
1316 return Ok(None);
1317 }
1318
1319 extract_layer(&local_layer, request.rootfs_dir)?;
1321 let local_size = std::fs::metadata(&local_layer)
1322 .map(|metadata| metadata.len())
1323 .unwrap_or(cached.size);
1324
1325 state.layers.push(LayerInfo {
1326 path: local_layer,
1327 digest: cached.digest,
1328 size: local_size,
1329 });
1330 state.diff_ids.push(cached.diff_id);
1331 state.history.push(HistoryEntry {
1332 created_by: request.created_by.to_string(),
1333 empty_layer: false,
1334 });
1335 Ok(Some(()))
1336}
1337
1338async fn handle_from(
1342 image: &str,
1343 rootfs_dir: &Path,
1344 _layers_dir: &Path,
1345 store: &Arc<ImageStore>,
1346 build_args: &HashMap<String, String>,
1347) -> Result<(Vec<LayerInfo>, Vec<String>, OciImageConfig)> {
1348 let image_ref = expand_args(image, build_args);
1349 if image_ref == "scratch" {
1350 return Ok((Vec::new(), Vec::new(), scratch_config()));
1351 }
1352
1353 let puller = ImagePuller::new(store.clone(), RegistryAuth::from_env());
1355 let oci_image = puller.pull(&image_ref).await?;
1356
1357 for layer_path in oci_image.layer_paths() {
1359 extract_layer(layer_path, rootfs_dir)?;
1360 }
1361
1362 let mut base_layers = Vec::new();
1364 let mut base_diff_ids = Vec::new();
1365
1366 for layer_path in oci_image.layer_paths() {
1367 let digest = sha256_file(layer_path)?;
1368 let size = std::fs::metadata(layer_path).map(|m| m.len()).unwrap_or(0);
1369
1370 let diff_id = compute_diff_id(layer_path)?;
1372 base_diff_ids.push(diff_id);
1373
1374 base_layers.push(LayerInfo {
1375 path: layer_path.to_path_buf(),
1376 digest,
1377 size,
1378 });
1379 }
1380
1381 let config = oci_image.config().clone();
1382 Ok((base_layers, base_diff_ids, config))
1383}
1384
1385async fn resolve_external_from_rootfs(
1389 image_ref: &str,
1390 operation: &str,
1391 store: &Arc<ImageStore>,
1392 build_dir: &Path,
1393 cache: &mut HashMap<String, PathBuf>,
1394) -> Result<PathBuf> {
1395 if let Some(dir) = cache.get(image_ref) {
1396 return Ok(dir.clone());
1397 }
1398
1399 let dir = build_dir.join(format!("copyfrom_{}", cache.len()));
1400 std::fs::create_dir_all(&dir).map_err(|e| {
1401 BoxError::BuildError(format!(
1402 "Failed to create {operation} image rootfs {}: {}",
1403 dir.display(),
1404 e
1405 ))
1406 })?;
1407
1408 let puller = ImagePuller::new(store.clone(), RegistryAuth::from_env());
1409 let oci_image = puller.pull(image_ref).await.map_err(|e| {
1410 BoxError::BuildError(format!(
1411 "{operation} from={}: not a build stage and could not be pulled as an image: {}",
1412 image_ref, e
1413 ))
1414 })?;
1415 for layer_path in oci_image.layer_paths() {
1416 extract_layer(layer_path, &dir)?;
1417 }
1418
1419 cache.insert(image_ref.to_string(), dir.clone());
1420 Ok(dir)
1421}
1422
1423fn validate_build_config(config: &BuildConfig) -> Result<()> {
1424 if config.platforms.len() > 1 {
1425 return Err(BoxError::BuildError(
1426 "Multi-platform builds are not implemented yet; pass a single target platform"
1427 .to_string(),
1428 ));
1429 }
1430
1431 for platform in &config.platforms {
1432 if platform.os != "linux" {
1433 return Err(BoxError::BuildError(format!(
1434 "Only linux target platforms are supported for image builds, got {}",
1435 platform
1436 )));
1437 }
1438 }
1439
1440 Ok(())
1441}
1442
1443fn default_target_platform() -> Platform {
1444 let host = Platform::host();
1445 Platform::new("linux", host.architecture)
1446}
1447
1448fn scratch_config() -> OciImageConfig {
1449 OciImageConfig {
1450 entrypoint: None,
1451 cmd: None,
1452 env: Vec::new(),
1453 working_dir: None,
1454 user: None,
1455 exposed_ports: Vec::new(),
1456 labels: HashMap::new(),
1457 volumes: Vec::new(),
1458 stop_signal: None,
1459 health_check: None,
1460 onbuild: Vec::new(),
1461 }
1462}
1463
1464async fn assemble_image(
1466 reference: &str,
1467 state: &BuildState,
1468 base_layers: &[LayerInfo],
1469 base_diff_ids: &[String],
1470 layers_dir: &Path,
1471 store: &Arc<ImageStore>,
1472 target_platform: &Platform,
1473) -> Result<BuildResult> {
1474 let output_dir = layers_dir.join("_output");
1476 let blobs_dir = output_dir.join("blobs").join("sha256");
1477 std::fs::create_dir_all(&blobs_dir)
1478 .map_err(|e| BoxError::BuildError(format!("Failed to create output blobs dir: {}", e)))?;
1479
1480 let mut all_layer_descriptors = Vec::new();
1482 let mut all_diff_ids: Vec<String> = base_diff_ids.to_vec();
1483
1484 for layer in base_layers {
1486 let blob_path = blobs_dir.join(&layer.digest);
1487 if !blob_path.exists() {
1488 copy_layer_blob(layer, &blob_path, "base layer")?;
1489 }
1490 all_layer_descriptors.push(serde_json::json!({
1491 "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
1492 "digest": layer.prefixed_digest(),
1493 "size": layer.size
1494 }));
1495 }
1496
1497 for (i, layer) in state.layers.iter().enumerate() {
1499 let blob_path = blobs_dir.join(&layer.digest);
1500 if !blob_path.exists() {
1501 copy_layer_blob(layer, &blob_path, &format!("layer {i}"))?;
1502 }
1503 all_layer_descriptors.push(serde_json::json!({
1504 "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
1505 "digest": layer.prefixed_digest(),
1506 "size": layer.size
1507 }));
1508 }
1509
1510 all_diff_ids.extend(state.diff_ids.iter().cloned());
1512
1513 let now = chrono::Utc::now().to_rfc3339();
1515 let arch = target_platform.oci_arch();
1516
1517 let env_list: Vec<String> = state
1518 .env
1519 .iter()
1520 .map(|(k, v)| format!("{}={}", k, v))
1521 .collect();
1522
1523 let mut config_obj = serde_json::json!({
1524 "architecture": arch,
1525 "os": "linux",
1526 "created": now,
1527 "config": {},
1528 "rootfs": {
1529 "type": "layers",
1530 "diff_ids": all_diff_ids.iter()
1531 .map(|d| format!("sha256:{}", d))
1532 .collect::<Vec<_>>()
1533 },
1534 "history": state.history.iter().map(|h| {
1535 let mut entry = serde_json::json!({
1536 "created": now,
1537 "created_by": h.created_by
1538 });
1539 if h.empty_layer {
1540 entry["empty_layer"] = serde_json::json!(true);
1541 }
1542 entry
1543 }).collect::<Vec<_>>()
1544 });
1545
1546 let config_section = config_obj["config"].as_object_mut().unwrap();
1548 if !env_list.is_empty() {
1549 config_section.insert("Env".to_string(), serde_json::json!(env_list));
1550 }
1551 if let Some(ref ep) = state.entrypoint {
1552 config_section.insert("Entrypoint".to_string(), serde_json::json!(ep));
1553 }
1554 if let Some(ref cmd) = state.cmd {
1555 config_section.insert("Cmd".to_string(), serde_json::json!(cmd));
1556 }
1557 if state.workdir != "/" {
1558 config_section.insert("WorkingDir".to_string(), serde_json::json!(state.workdir));
1559 }
1560 if let Some(ref user) = state.user {
1561 config_section.insert("User".to_string(), serde_json::json!(user));
1562 }
1563 if !state.exposed_ports.is_empty() {
1564 let ports: HashMap<String, serde_json::Value> = state
1565 .exposed_ports
1566 .iter()
1567 .map(|p| (p.clone(), serde_json::json!({})))
1568 .collect();
1569 config_section.insert("ExposedPorts".to_string(), serde_json::json!(ports));
1570 }
1571 if !state.labels.is_empty() {
1572 config_section.insert("Labels".to_string(), serde_json::json!(state.labels));
1573 }
1574 if let Some(ref sig) = state.stop_signal {
1575 config_section.insert("StopSignal".to_string(), serde_json::json!(sig));
1576 }
1577 if let Some(ref hc) = state.health_check {
1578 let mut hc_obj = serde_json::json!({
1579 "Test": hc.test,
1580 });
1581 if let Some(interval) = hc.interval {
1582 hc_obj["Interval"] = serde_json::json!(interval * 1_000_000_000);
1584 }
1585 if let Some(timeout) = hc.timeout {
1586 hc_obj["Timeout"] = serde_json::json!(timeout * 1_000_000_000);
1587 }
1588 if let Some(retries) = hc.retries {
1589 hc_obj["Retries"] = serde_json::json!(retries);
1590 }
1591 if let Some(start_period) = hc.start_period {
1592 hc_obj["StartPeriod"] = serde_json::json!(start_period * 1_000_000_000);
1593 }
1594 config_section.insert("Healthcheck".to_string(), hc_obj);
1595 }
1596 if !state.onbuild.is_empty() {
1597 config_section.insert("OnBuild".to_string(), serde_json::json!(state.onbuild));
1598 }
1599 if !state.volumes.is_empty() {
1600 let vols: HashMap<String, serde_json::Value> = state
1601 .volumes
1602 .iter()
1603 .map(|v| (v.clone(), serde_json::json!({})))
1604 .collect();
1605 config_section.insert("Volumes".to_string(), serde_json::json!(vols));
1606 }
1607
1608 let config_bytes = serde_json::to_vec_pretty(&config_obj)?;
1610 let config_digest = sha256_bytes(&config_bytes);
1611 std::fs::write(blobs_dir.join(&config_digest), &config_bytes)
1612 .map_err(|e| BoxError::BuildError(format!("Failed to write config blob: {}", e)))?;
1613
1614 let manifest = serde_json::json!({
1616 "schemaVersion": 2,
1617 "mediaType": "application/vnd.oci.image.manifest.v1+json",
1618 "config": {
1619 "mediaType": "application/vnd.oci.image.config.v1+json",
1620 "digest": format!("sha256:{}", config_digest),
1621 "size": config_bytes.len()
1622 },
1623 "layers": all_layer_descriptors
1624 });
1625
1626 let manifest_bytes = serde_json::to_vec_pretty(&manifest)?;
1627 let manifest_digest = sha256_bytes(&manifest_bytes);
1628 std::fs::write(blobs_dir.join(&manifest_digest), &manifest_bytes)
1629 .map_err(|e| BoxError::BuildError(format!("Failed to write manifest blob: {}", e)))?;
1630
1631 let mut platform_obj = serde_json::json!({
1633 "os": target_platform.os,
1634 "architecture": target_platform.architecture
1635 });
1636 if let Some(ref variant) = target_platform.variant {
1637 platform_obj["variant"] = serde_json::json!(variant);
1638 }
1639
1640 let index = serde_json::json!({
1641 "schemaVersion": 2,
1642 "mediaType": "application/vnd.oci.image.index.v1+json",
1643 "manifests": [{
1644 "mediaType": "application/vnd.oci.image.manifest.v1+json",
1645 "digest": format!("sha256:{}", manifest_digest),
1646 "size": manifest_bytes.len(),
1647 "platform": platform_obj
1648 }]
1649 });
1650 std::fs::write(
1651 output_dir.join("index.json"),
1652 serde_json::to_string_pretty(&index)?,
1653 )
1654 .map_err(|e| BoxError::BuildError(format!("Failed to write index.json: {}", e)))?;
1655
1656 std::fs::write(
1658 output_dir.join("oci-layout"),
1659 r#"{"imageLayoutVersion":"1.0.0"}"#,
1660 )
1661 .map_err(|e| BoxError::BuildError(format!("Failed to write oci-layout: {}", e)))?;
1662
1663 let digest_str = format!("sha256:{}", manifest_digest);
1665 let stored = store.put(reference, &digest_str, &output_dir).await?;
1666
1667 let total_layers = base_layers.len() + state.layers.len();
1668
1669 Ok(BuildResult {
1670 reference: reference.to_string(),
1671 digest: digest_str,
1672 size: stored.size_bytes,
1673 layer_count: total_layers,
1674 })
1675}
1676
1677fn copy_layer_blob(layer: &LayerInfo, blob_path: &Path, label: &str) -> Result<()> {
1678 if !layer.path.exists() {
1679 return Err(BoxError::BuildError(format!(
1680 "Failed to copy {label}: source layer {} for digest {} does not exist",
1681 layer.path.display(),
1682 layer.prefixed_digest()
1683 )));
1684 }
1685
1686 std::fs::copy(&layer.path, blob_path).map_err(|e| {
1687 BoxError::BuildError(format!(
1688 "Failed to copy {label} from {} to {} (digest {}): {}",
1689 layer.path.display(),
1690 blob_path.display(),
1691 layer.prefixed_digest(),
1692 e
1693 ))
1694 })?;
1695 Ok(())
1696}