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};
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 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}
61
62#[derive(Debug)]
64pub struct BuildResult {
65 pub reference: String,
67 pub digest: String,
69 pub size: u64,
71 pub layer_count: usize,
73}
74
75pub(super) struct BuildState {
77 pub(super) workdir: String,
79 pub(super) env: Vec<(String, String)>,
81 pub(super) entrypoint: Option<Vec<String>>,
83 pub(super) cmd: Option<Vec<String>>,
85 pub(super) user: Option<String>,
87 pub(super) exposed_ports: Vec<String>,
89 pub(super) labels: HashMap<String, String>,
91 pub(super) layers: Vec<LayerInfo>,
93 pub(super) diff_ids: Vec<String>,
95 pub(super) history: Vec<HistoryEntry>,
97 pub(super) build_args: HashMap<String, String>,
100 pub(super) declared_args: HashSet<String>,
104 pub(super) shell: Vec<String>,
106 pub(super) stop_signal: Option<String>,
108 pub(super) health_check: Option<OciHealthCheck>,
110 pub(super) onbuild: Vec<String>,
112 pub(super) volumes: Vec<String>,
114}
115
116#[derive(Debug, Clone)]
118pub(super) struct HistoryEntry {
119 pub(super) created_by: String,
120 pub(super) empty_layer: bool,
121}
122
123pub use crate::oci::image::OciHealthCheck;
124
125impl BuildState {
126 fn new(build_args: HashMap<String, String>) -> Self {
127 Self {
128 workdir: "/".to_string(),
129 env: Vec::new(),
130 entrypoint: None,
131 cmd: None,
132 user: None,
133 exposed_ports: Vec::new(),
134 labels: HashMap::new(),
135 layers: Vec::new(),
136 diff_ids: Vec::new(),
137 history: Vec::new(),
138 build_args,
139 declared_args: HashSet::new(),
140 shell: vec!["/bin/sh".to_string(), "-c".to_string()],
141 stop_signal: None,
142 health_check: None,
143 onbuild: Vec::new(),
144 volumes: Vec::new(),
145 }
146 }
147
148 fn declared_build_args(&self) -> HashMap<String, String> {
151 self.build_args
152 .iter()
153 .filter(|(name, _)| self.declared_args.contains(*name))
154 .map(|(name, value)| (name.clone(), value.clone()))
155 .collect()
156 }
157
158 fn expansion_vars(&self) -> HashMap<String, String> {
162 let mut vars = self.declared_build_args();
163 for (key, value) in &self.env {
164 vars.insert(key.clone(), value.clone());
165 }
166 vars
167 }
168
169 fn run_env(&self) -> Vec<(String, String)> {
173 let mut vars = self.declared_build_args();
174 for (key, value) in &self.env {
175 vars.insert(key.clone(), value.clone());
176 }
177 let mut pairs = vars.into_iter().collect::<Vec<_>>();
178 pairs.sort_by(|a, b| a.0.cmp(&b.0));
179 pairs
180 }
181
182 fn seed_global_arg(&mut self, name: &str, default: Option<&str>) {
185 self.declared_args.insert(name.to_string());
186 if !self.build_args.contains_key(name) {
187 if let Some(val) = default {
188 self.build_args.insert(name.to_string(), val.to_string());
189 }
190 }
191 }
192}
193
194pub async fn build(config: BuildConfig, store: Arc<ImageStore>) -> Result<BuildResult> {
209 validate_build_config(&config)?;
210
211 let dockerfile = Dockerfile::from_file(&config.dockerfile_path)?;
213
214 let dockerignore = DockerIgnore::load(&config.context_dir);
216
217 if !config.quiet {
218 println!("Building from {}", config.dockerfile_path.display());
219 if !dockerignore.is_empty() {
220 println!("Using .dockerignore");
221 }
222 }
223
224 let stages = split_into_stages(&dockerfile.instructions);
226 let global_args = global_arg_decls(&dockerfile.instructions);
230 let total_stages = stages.len();
231
232 let output_stage_idx = match config.target.as_deref() {
236 Some(target) => stages
237 .iter()
238 .position(|s| s.alias.as_deref() == Some(target))
239 .or_else(|| target.parse::<usize>().ok().filter(|i| *i < total_stages))
240 .ok_or_else(|| {
241 BoxError::BuildError(format!("target build stage '{}' not found", target))
242 })?,
243 None => total_stages - 1,
244 };
245
246 let mut completed_stages: Vec<(Option<String>, PathBuf)> = Vec::new();
248 let mut external_from_rootfs: HashMap<String, PathBuf> = HashMap::new();
251
252 let build_dir = tempfile::TempDir::new()
254 .map_err(|e| BoxError::BuildError(format!("Failed to create build directory: {}", e)))?;
255
256 let mut final_state = BuildState::new(config.build_args.clone());
257 let mut final_base_layers: Vec<LayerInfo> = Vec::new();
258 let mut final_base_diff_ids: Vec<String> = Vec::new();
259
260 let total_instructions = dockerfile.instructions.len();
261 let mut global_step = 0;
262
263 for (stage_idx, stage) in stages.iter().enumerate() {
264 let is_final_stage = stage_idx == output_stage_idx;
265
266 let rootfs_dir = build_dir.path().join(format!("rootfs_{}", stage_idx));
267 let layers_dir = build_dir.path().join(format!("layers_{}", stage_idx));
268 std::fs::create_dir_all(&rootfs_dir).map_err(|e| {
269 BoxError::BuildError(format!("Failed to create rootfs directory: {}", e))
270 })?;
271 std::fs::create_dir_all(&layers_dir).map_err(|e| {
272 BoxError::BuildError(format!("Failed to create layers directory: {}", e))
273 })?;
274
275 let mut state = BuildState::new(config.build_args.clone());
276 if stage_idx > 0 {
280 for (name, default) in &global_args {
281 state.seed_global_arg(name, default.as_deref());
282 }
283 }
284 let mut base_layers: Vec<LayerInfo> = Vec::new();
285 let mut base_diff_ids: Vec<String> = Vec::new();
286
287 let cache = if config.no_cache {
289 None
290 } else {
291 BuildCache::open()
292 };
293 let mut chain_key = String::new();
295 let mut cache_valid = true;
297
298 for instruction in &stage.instructions {
299 global_step += 1;
300 let step = global_step;
301
302 if !matches!(instruction, Instruction::From { .. }) {
307 let repr = match instruction {
313 Instruction::Env { vars } => {
314 let pairs: Vec<String> = vars
315 .iter()
316 .map(|(k, v)| {
317 format!("{}={}", k, expand_args(v, &state.expansion_vars()))
318 })
319 .collect();
320 format!("ENV {}", pairs.join(" "))
321 }
322 Instruction::Arg { name, default } => {
323 let effective = state
324 .build_args
325 .get(name)
326 .cloned()
327 .or_else(|| default.clone())
328 .unwrap_or_default();
329 format!("ARG {}={}", name, effective)
330 }
331 other => instruction_to_string(other),
332 };
333 let input_hash = match instruction {
334 Instruction::Copy {
335 src, from: None, ..
336 } => hash_context_sources(&config.context_dir, src),
337 Instruction::Copy {
338 src,
339 from: Some(from_ref),
340 ..
341 } => {
342 resolve_stage_rootfs(from_ref, &completed_stages)
350 .ok()
351 .and_then(|rootfs| hash_context_sources(rootfs, src))
352 }
353 Instruction::Add { src, .. } => hash_context_sources(&config.context_dir, src),
354 _ => None,
355 };
356 chain_key = BuildCache::chain(&chain_key, &repr, input_hash.as_deref());
357 }
358
359 match instruction {
360 Instruction::From { image, alias } => {
361 if !config.quiet {
362 if total_stages > 1 {
363 println!(
364 "Step {}/{}: FROM {} (stage {}/{}{})",
365 step,
366 total_instructions,
367 image,
368 stage_idx + 1,
369 total_stages,
370 alias
371 .as_ref()
372 .map(|a| format!(" as {}", a))
373 .unwrap_or_default()
374 );
375 } else {
376 println!("Step {}/{}: FROM {}", step, total_instructions, image);
377 }
378 }
379 let (layers, diff_ids, base_config) = handle_from(
380 image,
381 &rootfs_dir,
382 &layers_dir,
383 &store,
384 &state.declared_build_args(),
385 )
386 .await?;
387 base_layers = layers;
388 base_diff_ids = diff_ids;
389
390 chain_key = sha256_bytes(base_diff_ids.join(",").as_bytes());
394 cache_valid = true;
395
396 apply_base_config(&mut state, &base_config);
398
399 if !base_config.onbuild.is_empty() && !config.quiet {
401 println!(
402 " Executing {} ONBUILD trigger(s) from base image",
403 base_config.onbuild.len()
404 );
405 }
406 for trigger in &base_config.onbuild {
407 execute_onbuild_trigger(
408 trigger,
409 &mut state,
410 &config,
411 &rootfs_dir,
412 &layers_dir,
413 &base_layers,
414 &completed_stages,
415 )?;
416 }
417
418 state.history.push(HistoryEntry {
419 created_by: format!("FROM {}", image),
420 empty_layer: true,
421 });
422 }
423
424 Instruction::Copy {
425 src,
426 dst,
427 from,
428 chown,
429 } => {
430 let created_by = if let Some(from_ref) = from {
431 format!("COPY --from={} {} {}", from_ref, src.join(" "), dst)
432 } else if let Some(owner) = chown {
433 format!("COPY --chown={} {} {}", owner, src.join(" "), dst)
434 } else {
435 format!("COPY {} {}", src.join(" "), dst)
436 };
437 if try_reuse_cached_layer(
438 CachedLayerReuse {
439 cache_valid,
440 cache: cache.as_ref(),
441 chain_key: &chain_key,
442 rootfs_dir: &rootfs_dir,
443 layers_dir: &layers_dir,
444 layer_index: state.layers.len() + base_layers.len(),
445 created_by: &created_by,
446 },
447 &mut state,
448 )?
449 .is_some()
450 {
451 if !config.quiet {
452 println!(
453 "Step {}/{}: {} (CACHED)",
454 step, total_instructions, created_by
455 );
456 }
457 continue;
458 }
459 cache_valid = false;
460
461 if let Some(from_ref) = from {
462 if !config.quiet {
463 println!(
464 "Step {}/{}: COPY --from={} {} {}",
465 step,
466 total_instructions,
467 from_ref,
468 src.join(" "),
469 dst
470 );
471 }
472 let from_rootfs: PathBuf =
476 match resolve_stage_rootfs(from_ref, &completed_stages) {
477 Ok(stage_rootfs) => stage_rootfs.to_path_buf(),
478 Err(_) => {
479 resolve_external_image_rootfs(
480 from_ref,
481 &store,
482 build_dir.path(),
483 &mut external_from_rootfs,
484 )
485 .await?
486 }
487 };
488 let layer_info = handle_copy(
491 src,
492 dst,
493 chown.as_deref(),
494 &from_rootfs,
495 &rootfs_dir,
496 &layers_dir,
497 &state.workdir,
498 state.layers.len() + base_layers.len(),
499 None,
500 )?;
501 let diff_id = compute_diff_id(&layer_info.path)?;
502 if let Some(c) = &cache {
503 c.store(&chain_key, &layer_info, &diff_id);
504 }
505 state.diff_ids.push(diff_id);
506 state.layers.push(layer_info);
507 state.history.push(HistoryEntry {
508 created_by: format!(
509 "COPY --from={} {} {}",
510 from_ref,
511 src.join(" "),
512 dst
513 ),
514 empty_layer: false,
515 });
516 } else {
517 if !config.quiet {
518 println!(
519 "Step {}/{}: COPY {} {}",
520 step,
521 total_instructions,
522 src.join(" "),
523 dst
524 );
525 }
526 let layer_info = handle_copy(
527 src,
528 dst,
529 chown.as_deref(),
530 &config.context_dir,
531 &rootfs_dir,
532 &layers_dir,
533 &state.workdir,
534 state.layers.len() + base_layers.len(),
535 Some(&dockerignore),
536 )?;
537 let diff_id = compute_diff_id(&layer_info.path)?;
538 if let Some(c) = &cache {
539 c.store(&chain_key, &layer_info, &diff_id);
540 }
541 state.diff_ids.push(diff_id);
542 state.layers.push(layer_info);
543 state.history.push(HistoryEntry {
544 created_by: format!("COPY {} {}", src.join(" "), dst),
545 empty_layer: false,
546 });
547 }
548 }
549
550 Instruction::Add { src, dst, chown } => {
551 let created_by = format!("ADD {} {}", src.join(" "), dst);
552 if try_reuse_cached_layer(
553 CachedLayerReuse {
554 cache_valid,
555 cache: cache.as_ref(),
556 chain_key: &chain_key,
557 rootfs_dir: &rootfs_dir,
558 layers_dir: &layers_dir,
559 layer_index: state.layers.len() + base_layers.len(),
560 created_by: &created_by,
561 },
562 &mut state,
563 )?
564 .is_some()
565 {
566 if !config.quiet {
567 println!(
568 "Step {}/{}: {} (CACHED)",
569 step, total_instructions, created_by
570 );
571 }
572 continue;
573 }
574 cache_valid = false;
575
576 if !config.quiet {
577 println!(
578 "Step {}/{}: ADD {} {}",
579 step,
580 total_instructions,
581 src.join(" "),
582 dst
583 );
584 }
585 let layer_info = handle_add(
586 src,
587 dst,
588 chown.as_deref(),
589 &config.context_dir,
590 &rootfs_dir,
591 &layers_dir,
592 &state.workdir,
593 state.layers.len() + base_layers.len(),
594 Some(&dockerignore),
595 )?;
596 let diff_id = compute_diff_id(&layer_info.path)?;
597 if let Some(c) = &cache {
598 c.store(&chain_key, &layer_info, &diff_id);
599 }
600 state.diff_ids.push(diff_id);
601 state.layers.push(layer_info);
602 state.history.push(HistoryEntry {
603 created_by: format!("ADD {} {}", src.join(" "), dst),
604 empty_layer: false,
605 });
606 }
607
608 Instruction::Run {
609 command,
610 cache_mounts,
611 } => {
612 let created_by = instruction_to_string(instruction);
613 if try_reuse_cached_layer(
614 CachedLayerReuse {
615 cache_valid,
616 cache: cache.as_ref(),
617 chain_key: &chain_key,
618 rootfs_dir: &rootfs_dir,
619 layers_dir: &layers_dir,
620 layer_index: state.layers.len() + base_layers.len(),
621 created_by: &created_by,
622 },
623 &mut state,
624 )?
625 .is_some()
626 {
627 if !config.quiet {
628 println!(
629 "Step {}/{}: {} (CACHED)",
630 step, total_instructions, created_by
631 );
632 }
633 continue;
634 }
635 cache_valid = false;
636
637 if !config.quiet {
638 println!("Step {}/{}: {}", step, total_instructions, created_by);
639 }
640 let layer_opt = handle_run(
641 command,
642 cache_mounts,
643 &rootfs_dir,
644 &layers_dir,
645 &state.workdir,
646 &state.run_env(),
647 &state.shell,
648 state.layers.len() + base_layers.len(),
649 config.quiet,
650 )?;
651 if let Some(layer_info) = layer_opt {
652 let diff_id = compute_diff_id(&layer_info.path)?;
653 if let Some(c) = &cache {
654 c.store(&chain_key, &layer_info, &diff_id);
655 }
656 state.diff_ids.push(diff_id);
657 state.layers.push(layer_info);
658 state.history.push(HistoryEntry {
659 created_by: created_by.clone(),
660 empty_layer: false,
661 });
662 } else {
663 state.history.push(HistoryEntry {
664 created_by: created_by.clone(),
665 empty_layer: true,
666 });
667 }
668 }
669
670 Instruction::Workdir { path } => {
671 if !config.quiet {
672 println!("Step {}/{}: WORKDIR {}", step, total_instructions, path);
673 }
674 let expanded_path = expand_args(path, &state.expansion_vars());
676 state.workdir = resolve_path(&state.workdir, &expanded_path);
677 let full = rootfs_dir.join(state.workdir.trim_start_matches('/'));
678 let _ = std::fs::create_dir_all(&full);
679 state.history.push(HistoryEntry {
680 created_by: format!("WORKDIR {}", path),
681 empty_layer: true,
682 });
683 }
684
685 Instruction::Env { vars } => {
686 let display: Vec<String> =
687 vars.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
688 let display = display.join(" ");
689 if !config.quiet {
690 println!("Step {}/{}: ENV {}", step, total_instructions, display);
691 }
692 for (key, value) in vars {
693 let expanded_value = expand_args(value, &state.expansion_vars());
696 if let Some(existing) = state.env.iter_mut().find(|(k, _)| k == key) {
697 existing.1 = expanded_value;
698 } else {
699 state.env.push((key.clone(), expanded_value));
700 }
701 }
702 state.history.push(HistoryEntry {
703 created_by: format!("ENV {}", display),
704 empty_layer: true,
705 });
706 }
707
708 Instruction::Entrypoint { exec } => {
709 if !config.quiet {
710 println!(
711 "Step {}/{}: ENTRYPOINT {:?}",
712 step, total_instructions, exec
713 );
714 }
715 state.entrypoint = Some(exec.clone());
716 state.history.push(HistoryEntry {
717 created_by: format!("ENTRYPOINT {:?}", exec),
718 empty_layer: true,
719 });
720 }
721
722 Instruction::Cmd { exec } => {
723 if !config.quiet {
724 println!("Step {}/{}: CMD {:?}", step, total_instructions, exec);
725 }
726 state.cmd = Some(exec.clone());
727 state.history.push(HistoryEntry {
728 created_by: format!("CMD {:?}", exec),
729 empty_layer: true,
730 });
731 }
732
733 Instruction::Expose { ports } => {
734 let joined = ports.join(" ");
735 if !config.quiet {
736 println!("Step {}/{}: EXPOSE {}", step, total_instructions, joined);
737 }
738 for port in ports {
739 if !state.exposed_ports.contains(port) {
740 state.exposed_ports.push(port.clone());
741 }
742 }
743 state.history.push(HistoryEntry {
744 created_by: format!("EXPOSE {}", joined),
745 empty_layer: true,
746 });
747 }
748
749 Instruction::Label { pairs } => {
750 let joined = pairs
751 .iter()
752 .map(|(k, v)| format!("{}={}", k, v))
753 .collect::<Vec<_>>()
754 .join(" ");
755 if !config.quiet {
756 println!("Step {}/{}: LABEL {}", step, total_instructions, joined);
757 }
758 for (key, value) in pairs {
759 state.labels.insert(key.clone(), value.clone());
760 }
761 state.history.push(HistoryEntry {
762 created_by: format!("LABEL {}", joined),
763 empty_layer: true,
764 });
765 }
766
767 Instruction::User { user } => {
768 if !config.quiet {
769 println!("Step {}/{}: USER {}", step, total_instructions, user);
770 }
771 state.user = Some(user.clone());
772 state.history.push(HistoryEntry {
773 created_by: format!("USER {}", user),
774 empty_layer: true,
775 });
776 }
777
778 Instruction::Arg { name, default } => {
779 if !config.quiet {
780 println!("Step {}/{}: ARG {}", step, total_instructions, name);
781 }
782 state.declared_args.insert(name.clone());
783 if !state.build_args.contains_key(name) {
784 if let Some(val) = default {
785 state.build_args.insert(name.clone(), val.clone());
786 }
787 }
788 state.history.push(HistoryEntry {
789 created_by: format!("ARG {}", name),
790 empty_layer: true,
791 });
792 }
793
794 Instruction::Shell { exec } => {
795 if !config.quiet {
796 println!("Step {}/{}: SHELL {:?}", step, total_instructions, exec);
797 }
798 state.shell = exec.clone();
799 state.history.push(HistoryEntry {
800 created_by: format!("SHELL {:?}", exec),
801 empty_layer: true,
802 });
803 }
804
805 Instruction::StopSignal { signal } => {
806 if !config.quiet {
807 println!(
808 "Step {}/{}: STOPSIGNAL {}",
809 step, total_instructions, signal
810 );
811 }
812 state.stop_signal = Some(signal.clone());
813 state.history.push(HistoryEntry {
814 created_by: format!("STOPSIGNAL {}", signal),
815 empty_layer: true,
816 });
817 }
818
819 Instruction::HealthCheck {
820 cmd,
821 interval,
822 timeout,
823 retries,
824 start_period,
825 } => {
826 if !config.quiet {
827 if cmd.is_some() {
828 println!("Step {}/{}: HEALTHCHECK CMD ...", step, total_instructions);
829 } else {
830 println!("Step {}/{}: HEALTHCHECK NONE", step, total_instructions);
831 }
832 }
833 state.health_check = cmd.as_ref().map(|c| OciHealthCheck {
834 test: c.clone(),
835 interval: *interval,
836 timeout: *timeout,
837 retries: *retries,
838 start_period: *start_period,
839 });
840 state.history.push(HistoryEntry {
841 created_by: if cmd.is_some() {
842 "HEALTHCHECK CMD ...".to_string()
843 } else {
844 "HEALTHCHECK NONE".to_string()
845 },
846 empty_layer: true,
847 });
848 }
849
850 Instruction::OnBuild { instruction } => {
851 let trigger = format!("{:?}", instruction);
852 if !config.quiet {
853 println!("Step {}/{}: ONBUILD {}", step, total_instructions, trigger);
854 }
855 state.onbuild.push(instruction_to_string(instruction));
857 state.history.push(HistoryEntry {
858 created_by: format!("ONBUILD {}", instruction_to_string(instruction)),
859 empty_layer: true,
860 });
861 }
862
863 Instruction::Volume { paths } => {
864 if !config.quiet {
865 println!(
866 "Step {}/{}: VOLUME {}",
867 step,
868 total_instructions,
869 paths.join(" ")
870 );
871 }
872 for p in paths {
873 if !state.volumes.contains(p) {
874 state.volumes.push(p.clone());
875 }
876 }
877 for p in paths {
879 let full = rootfs_dir.join(p.trim_start_matches('/'));
880 let _ = std::fs::create_dir_all(&full);
881 }
882 state.history.push(HistoryEntry {
883 created_by: format!("VOLUME {}", paths.join(" ")),
884 empty_layer: true,
885 });
886 }
887 }
888 }
889
890 completed_stages.push((stage.alias.clone(), rootfs_dir.clone()));
892
893 if is_final_stage {
894 final_state = state;
895 final_base_layers = base_layers;
896 final_base_diff_ids = base_diff_ids;
897 break;
899 }
900 }
901
902 let reference = config
904 .tag
905 .clone()
906 .unwrap_or_else(|| "a3s-build:latest".to_string());
907
908 let final_layers_dir = build_dir
909 .path()
910 .join(format!("layers_{}", output_stage_idx));
911
912 let target_platform = config
914 .platforms
915 .first()
916 .cloned()
917 .unwrap_or_else(default_target_platform);
918
919 let result = assemble_image(
920 &reference,
921 &final_state,
922 &final_base_layers,
923 &final_base_diff_ids,
924 &final_layers_dir,
925 &store,
926 &target_platform,
927 )
928 .await?;
929
930 if !config.quiet {
931 println!(
932 "Successfully built {} ({} layers, {}, {})",
933 reference,
934 result.layer_count,
935 format_size(result.size),
936 target_platform,
937 );
938 }
939
940 if let Some(ref m) = config.metrics {
941 m.image_build_total.inc();
942 }
943
944 Ok(result)
945}
946
947struct CachedLayerReuse<'a> {
959 cache_valid: bool,
960 cache: Option<&'a BuildCache>,
961 chain_key: &'a str,
962 rootfs_dir: &'a Path,
963 layers_dir: &'a Path,
964 layer_index: usize,
965 created_by: &'a str,
966}
967
968fn try_reuse_cached_layer(
969 request: CachedLayerReuse<'_>,
970 state: &mut BuildState,
971) -> Result<Option<()>> {
972 if !request.cache_valid {
973 return Ok(None);
974 }
975 let Some(cached) = request.cache.and_then(|c| c.lookup(request.chain_key)) else {
976 return Ok(None);
977 };
978
979 let local_layer = request.layers_dir.join(format!(
980 "cached_{}_{}.tar.gz",
981 request.layer_index, cached.digest
982 ));
983 if let Err(error) = std::fs::copy(&cached.blob_path, &local_layer) {
984 tracing::warn!(
985 key = %request.chain_key,
986 source = %cached.blob_path.display(),
987 error = %error,
988 "Build cache blob disappeared before it could be materialized; rebuilding instruction"
989 );
990 return Ok(None);
991 }
992
993 extract_layer(&local_layer, request.rootfs_dir)?;
995 let local_size = std::fs::metadata(&local_layer)
996 .map(|metadata| metadata.len())
997 .unwrap_or(cached.size);
998
999 state.layers.push(LayerInfo {
1000 path: local_layer,
1001 digest: cached.digest,
1002 size: local_size,
1003 });
1004 state.diff_ids.push(cached.diff_id);
1005 state.history.push(HistoryEntry {
1006 created_by: request.created_by.to_string(),
1007 empty_layer: false,
1008 });
1009 Ok(Some(()))
1010}
1011
1012async fn handle_from(
1016 image: &str,
1017 rootfs_dir: &Path,
1018 _layers_dir: &Path,
1019 store: &Arc<ImageStore>,
1020 build_args: &HashMap<String, String>,
1021) -> Result<(Vec<LayerInfo>, Vec<String>, OciImageConfig)> {
1022 let image_ref = expand_args(image, build_args);
1023 if image_ref == "scratch" {
1024 return Ok((Vec::new(), Vec::new(), scratch_config()));
1025 }
1026
1027 let puller = ImagePuller::new(store.clone(), RegistryAuth::from_env());
1029 let oci_image = puller.pull(&image_ref).await?;
1030
1031 for layer_path in oci_image.layer_paths() {
1033 extract_layer(layer_path, rootfs_dir)?;
1034 }
1035
1036 let mut base_layers = Vec::new();
1038 let mut base_diff_ids = Vec::new();
1039
1040 for layer_path in oci_image.layer_paths() {
1041 let digest = sha256_file(layer_path)?;
1042 let size = std::fs::metadata(layer_path).map(|m| m.len()).unwrap_or(0);
1043
1044 let diff_id = compute_diff_id(layer_path)?;
1046 base_diff_ids.push(diff_id);
1047
1048 base_layers.push(LayerInfo {
1049 path: layer_path.to_path_buf(),
1050 digest,
1051 size,
1052 });
1053 }
1054
1055 let config = oci_image.config().clone();
1056 Ok((base_layers, base_diff_ids, config))
1057}
1058
1059async fn resolve_external_image_rootfs(
1063 image_ref: &str,
1064 store: &Arc<ImageStore>,
1065 build_dir: &Path,
1066 cache: &mut HashMap<String, PathBuf>,
1067) -> Result<PathBuf> {
1068 if let Some(dir) = cache.get(image_ref) {
1069 return Ok(dir.clone());
1070 }
1071
1072 let dir = build_dir.join(format!("copyfrom_{}", cache.len()));
1073 std::fs::create_dir_all(&dir).map_err(|e| {
1074 BoxError::BuildError(format!(
1075 "Failed to create COPY --from image rootfs {}: {}",
1076 dir.display(),
1077 e
1078 ))
1079 })?;
1080
1081 let puller = ImagePuller::new(store.clone(), RegistryAuth::from_env());
1082 let oci_image = puller.pull(image_ref).await.map_err(|e| {
1083 BoxError::BuildError(format!(
1084 "COPY --from={}: not a build stage and could not be pulled as an image: {}",
1085 image_ref, e
1086 ))
1087 })?;
1088 for layer_path in oci_image.layer_paths() {
1089 extract_layer(layer_path, &dir)?;
1090 }
1091
1092 cache.insert(image_ref.to_string(), dir.clone());
1093 Ok(dir)
1094}
1095
1096fn validate_build_config(config: &BuildConfig) -> Result<()> {
1097 if config.platforms.len() > 1 {
1098 return Err(BoxError::BuildError(
1099 "Multi-platform builds are not implemented yet; pass a single target platform"
1100 .to_string(),
1101 ));
1102 }
1103
1104 for platform in &config.platforms {
1105 if platform.os != "linux" {
1106 return Err(BoxError::BuildError(format!(
1107 "Only linux target platforms are supported for image builds, got {}",
1108 platform
1109 )));
1110 }
1111 }
1112
1113 Ok(())
1114}
1115
1116fn default_target_platform() -> Platform {
1117 let host = Platform::host();
1118 Platform::new("linux", host.architecture)
1119}
1120
1121fn scratch_config() -> OciImageConfig {
1122 OciImageConfig {
1123 entrypoint: None,
1124 cmd: None,
1125 env: Vec::new(),
1126 working_dir: None,
1127 user: None,
1128 exposed_ports: Vec::new(),
1129 labels: HashMap::new(),
1130 volumes: Vec::new(),
1131 stop_signal: None,
1132 health_check: None,
1133 onbuild: Vec::new(),
1134 }
1135}
1136
1137async fn assemble_image(
1139 reference: &str,
1140 state: &BuildState,
1141 base_layers: &[LayerInfo],
1142 base_diff_ids: &[String],
1143 layers_dir: &Path,
1144 store: &Arc<ImageStore>,
1145 target_platform: &Platform,
1146) -> Result<BuildResult> {
1147 let output_dir = layers_dir.join("_output");
1149 let blobs_dir = output_dir.join("blobs").join("sha256");
1150 std::fs::create_dir_all(&blobs_dir)
1151 .map_err(|e| BoxError::BuildError(format!("Failed to create output blobs dir: {}", e)))?;
1152
1153 let mut all_layer_descriptors = Vec::new();
1155 let mut all_diff_ids: Vec<String> = base_diff_ids.to_vec();
1156
1157 for layer in base_layers {
1159 let blob_path = blobs_dir.join(&layer.digest);
1160 if !blob_path.exists() {
1161 copy_layer_blob(layer, &blob_path, "base layer")?;
1162 }
1163 all_layer_descriptors.push(serde_json::json!({
1164 "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
1165 "digest": layer.prefixed_digest(),
1166 "size": layer.size
1167 }));
1168 }
1169
1170 for (i, layer) in state.layers.iter().enumerate() {
1172 let blob_path = blobs_dir.join(&layer.digest);
1173 if !blob_path.exists() {
1174 copy_layer_blob(layer, &blob_path, &format!("layer {i}"))?;
1175 }
1176 all_layer_descriptors.push(serde_json::json!({
1177 "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
1178 "digest": layer.prefixed_digest(),
1179 "size": layer.size
1180 }));
1181 }
1182
1183 all_diff_ids.extend(state.diff_ids.iter().cloned());
1185
1186 let now = chrono::Utc::now().to_rfc3339();
1188 let arch = target_platform.oci_arch();
1189
1190 let env_list: Vec<String> = state
1191 .env
1192 .iter()
1193 .map(|(k, v)| format!("{}={}", k, v))
1194 .collect();
1195
1196 let mut config_obj = serde_json::json!({
1197 "architecture": arch,
1198 "os": "linux",
1199 "created": now,
1200 "config": {},
1201 "rootfs": {
1202 "type": "layers",
1203 "diff_ids": all_diff_ids.iter()
1204 .map(|d| format!("sha256:{}", d))
1205 .collect::<Vec<_>>()
1206 },
1207 "history": state.history.iter().map(|h| {
1208 let mut entry = serde_json::json!({
1209 "created": now,
1210 "created_by": h.created_by
1211 });
1212 if h.empty_layer {
1213 entry["empty_layer"] = serde_json::json!(true);
1214 }
1215 entry
1216 }).collect::<Vec<_>>()
1217 });
1218
1219 let config_section = config_obj["config"].as_object_mut().unwrap();
1221 if !env_list.is_empty() {
1222 config_section.insert("Env".to_string(), serde_json::json!(env_list));
1223 }
1224 if let Some(ref ep) = state.entrypoint {
1225 config_section.insert("Entrypoint".to_string(), serde_json::json!(ep));
1226 }
1227 if let Some(ref cmd) = state.cmd {
1228 config_section.insert("Cmd".to_string(), serde_json::json!(cmd));
1229 }
1230 if state.workdir != "/" {
1231 config_section.insert("WorkingDir".to_string(), serde_json::json!(state.workdir));
1232 }
1233 if let Some(ref user) = state.user {
1234 config_section.insert("User".to_string(), serde_json::json!(user));
1235 }
1236 if !state.exposed_ports.is_empty() {
1237 let ports: HashMap<String, serde_json::Value> = state
1238 .exposed_ports
1239 .iter()
1240 .map(|p| (p.clone(), serde_json::json!({})))
1241 .collect();
1242 config_section.insert("ExposedPorts".to_string(), serde_json::json!(ports));
1243 }
1244 if !state.labels.is_empty() {
1245 config_section.insert("Labels".to_string(), serde_json::json!(state.labels));
1246 }
1247 if let Some(ref sig) = state.stop_signal {
1248 config_section.insert("StopSignal".to_string(), serde_json::json!(sig));
1249 }
1250 if let Some(ref hc) = state.health_check {
1251 let mut hc_obj = serde_json::json!({
1252 "Test": hc.test,
1253 });
1254 if let Some(interval) = hc.interval {
1255 hc_obj["Interval"] = serde_json::json!(interval * 1_000_000_000);
1257 }
1258 if let Some(timeout) = hc.timeout {
1259 hc_obj["Timeout"] = serde_json::json!(timeout * 1_000_000_000);
1260 }
1261 if let Some(retries) = hc.retries {
1262 hc_obj["Retries"] = serde_json::json!(retries);
1263 }
1264 if let Some(start_period) = hc.start_period {
1265 hc_obj["StartPeriod"] = serde_json::json!(start_period * 1_000_000_000);
1266 }
1267 config_section.insert("Healthcheck".to_string(), hc_obj);
1268 }
1269 if !state.onbuild.is_empty() {
1270 config_section.insert("OnBuild".to_string(), serde_json::json!(state.onbuild));
1271 }
1272 if !state.volumes.is_empty() {
1273 let vols: HashMap<String, serde_json::Value> = state
1274 .volumes
1275 .iter()
1276 .map(|v| (v.clone(), serde_json::json!({})))
1277 .collect();
1278 config_section.insert("Volumes".to_string(), serde_json::json!(vols));
1279 }
1280
1281 let config_bytes = serde_json::to_vec_pretty(&config_obj)?;
1283 let config_digest = sha256_bytes(&config_bytes);
1284 std::fs::write(blobs_dir.join(&config_digest), &config_bytes)
1285 .map_err(|e| BoxError::BuildError(format!("Failed to write config blob: {}", e)))?;
1286
1287 let manifest = serde_json::json!({
1289 "schemaVersion": 2,
1290 "mediaType": "application/vnd.oci.image.manifest.v1+json",
1291 "config": {
1292 "mediaType": "application/vnd.oci.image.config.v1+json",
1293 "digest": format!("sha256:{}", config_digest),
1294 "size": config_bytes.len()
1295 },
1296 "layers": all_layer_descriptors
1297 });
1298
1299 let manifest_bytes = serde_json::to_vec_pretty(&manifest)?;
1300 let manifest_digest = sha256_bytes(&manifest_bytes);
1301 std::fs::write(blobs_dir.join(&manifest_digest), &manifest_bytes)
1302 .map_err(|e| BoxError::BuildError(format!("Failed to write manifest blob: {}", e)))?;
1303
1304 let mut platform_obj = serde_json::json!({
1306 "os": target_platform.os,
1307 "architecture": target_platform.architecture
1308 });
1309 if let Some(ref variant) = target_platform.variant {
1310 platform_obj["variant"] = serde_json::json!(variant);
1311 }
1312
1313 let index = serde_json::json!({
1314 "schemaVersion": 2,
1315 "mediaType": "application/vnd.oci.image.index.v1+json",
1316 "manifests": [{
1317 "mediaType": "application/vnd.oci.image.manifest.v1+json",
1318 "digest": format!("sha256:{}", manifest_digest),
1319 "size": manifest_bytes.len(),
1320 "platform": platform_obj
1321 }]
1322 });
1323 std::fs::write(
1324 output_dir.join("index.json"),
1325 serde_json::to_string_pretty(&index)?,
1326 )
1327 .map_err(|e| BoxError::BuildError(format!("Failed to write index.json: {}", e)))?;
1328
1329 std::fs::write(
1331 output_dir.join("oci-layout"),
1332 r#"{"imageLayoutVersion":"1.0.0"}"#,
1333 )
1334 .map_err(|e| BoxError::BuildError(format!("Failed to write oci-layout: {}", e)))?;
1335
1336 let digest_str = format!("sha256:{}", manifest_digest);
1338 let stored = store.put(reference, &digest_str, &output_dir).await?;
1339
1340 let total_layers = base_layers.len() + state.layers.len();
1341
1342 Ok(BuildResult {
1343 reference: reference.to_string(),
1344 digest: digest_str,
1345 size: stored.size_bytes,
1346 layer_count: total_layers,
1347 })
1348}
1349
1350fn copy_layer_blob(layer: &LayerInfo, blob_path: &Path, label: &str) -> Result<()> {
1351 if !layer.path.exists() {
1352 return Err(BoxError::BuildError(format!(
1353 "Failed to copy {label}: source layer {} for digest {} does not exist",
1354 layer.path.display(),
1355 layer.prefixed_digest()
1356 )));
1357 }
1358
1359 std::fs::copy(&layer.path, blob_path).map_err(|e| {
1360 BoxError::BuildError(format!(
1361 "Failed to copy {label} from {} to {} (digest {}): {}",
1362 layer.path.display(),
1363 blob_path.display(),
1364 layer.prefixed_digest(),
1365 e
1366 ))
1367 })?;
1368 Ok(())
1369}