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::{
15 hash_context_sources, BuildCache, BuildCacheExportIdentity, BuildCacheTrace, CachedLayer,
16 RecordedBuildCache,
17};
18use super::dockerfile::{Dockerfile, Instruction, RunBindMount, RunCacheMount};
19use super::dockerignore::DockerIgnore;
20use super::layer::{sha256_bytes, sha256_file, LayerInfo};
21use super::output::publish_single_build_output;
22pub use super::output::{BuildOutputDescriptor, BuildResult, OCI_IMAGE_MANIFEST_MEDIA_TYPE};
23use crate::oci::image::OciImageConfig;
24use crate::oci::layers::extract_layer;
25use crate::oci::store::ImageStore;
26use crate::oci::{ImagePuller, RegistryAuth};
27
28mod control;
29mod handlers;
30#[cfg(target_os = "linux")]
31mod run_process;
32mod stages;
33mod utils;
34
35#[cfg(test)]
36mod tests;
37
38use handlers::{
39 apply_base_config, execute_onbuild_trigger, handle_add, handle_copy, handle_run,
40 handle_run_with_pool, instruction_to_string,
41};
42use stages::{global_arg_decls, resolve_stage_rootfs, split_into_stages};
43use utils::{compute_diff_id, expand_args, format_size, resolve_path};
44
45const REPRODUCIBLE_OCI_CREATED_AT: &str = "1970-01-01T00:00:00Z";
47
48pub(super) use control::{BuildExecutionControl, BuildExecutionObserver, BuildImageCommitPermit};
49
50pub(super) struct SupervisedBuildResult {
51 pub(super) output: BuildResult,
52 pub(super) cache: Option<RecordedBuildCache>,
53}
54
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
61pub enum BuildNetworkPolicy {
62 #[default]
64 Outbound,
65 None,
67}
68
69impl BuildNetworkPolicy {
70 pub const fn as_acl(self) -> &'static str {
72 match self {
73 Self::Outbound => "outbound",
74 Self::None => "none",
75 }
76 }
77
78 pub(crate) fn parse_acl(value: &str) -> Option<Self> {
79 match value {
80 "outbound" => Some(Self::Outbound),
81 "none" => Some(Self::None),
82 _ => None,
83 }
84 }
85}
86
87#[derive(Debug, Clone)]
89pub struct BuildConfig {
90 pub context_dir: PathBuf,
92 pub dockerfile_path: PathBuf,
94 pub tag: Option<String>,
96 pub build_args: HashMap<String, String>,
98 pub quiet: bool,
100 pub platforms: Vec<Platform>,
103 pub target: Option<String>,
106 pub no_cache: bool,
108 pub network: BuildNetworkPolicy,
110 pub metrics: Option<crate::prom::RuntimeMetrics>,
112 pub run_pool: Option<BuildRunPoolConfig>,
114}
115
116#[derive(Debug, Clone)]
118pub struct BuildRunPoolConfig {
119 pub socket: String,
121 pub image: Option<String>,
123 pub vcpus: u32,
125 pub memory_mb: u32,
127 pub guest_rootfs: String,
129 pub timeout_ns: u64,
131 pub run_cache_dir: PathBuf,
133}
134
135#[cfg_attr(not(feature = "pool"), allow(dead_code))]
136struct BuildRunPoolSession {
137 guest_rootfs: String,
138 timeout_ns: u64,
139 run_cache_dir: PathBuf,
140 #[cfg(feature = "pool")]
141 lease: crate::pool::PoolLeaseClient,
142}
143
144impl BuildRunPoolSession {
145 async fn acquire(config: &BuildRunPoolConfig, rootfs_dir: &Path) -> Result<Self> {
146 #[cfg(feature = "pool")]
147 {
148 let rootfs_dir = rootfs_dir.canonicalize().map_err(|e| {
149 BoxError::BuildError(format!(
150 "Failed to canonicalize build RUN rootfs {}: {}",
151 rootfs_dir.display(),
152 e
153 ))
154 })?;
155 let volume = format!("{}:{}:rw", rootfs_dir.display(), config.guest_rootfs);
156 let lease = crate::pool::PoolLeaseClient::acquire(crate::pool::PoolClientLease {
157 socket: config.socket.clone(),
158 image: config.image.clone(),
159 volumes: vec![volume],
160 vcpus: config.vcpus,
161 memory_mb: config.memory_mb,
162 })
163 .await
164 .map_err(|e| {
165 BoxError::BuildError(format!(
166 "Failed to lease warm-pool VM for Dockerfile RUN: {}",
167 e
168 ))
169 })?;
170 Ok(Self {
171 guest_rootfs: config.guest_rootfs.clone(),
172 timeout_ns: config.timeout_ns,
173 run_cache_dir: config.run_cache_dir.clone(),
174 lease,
175 })
176 }
177
178 #[cfg(not(feature = "pool"))]
179 {
180 let _ = (config, rootfs_dir);
181 Err(BoxError::BuildError(
182 "Dockerfile RUN warm-pool execution requires the runtime 'pool' feature"
183 .to_string(),
184 ))
185 }
186 }
187
188 async fn release(self) -> Result<()> {
189 #[cfg(feature = "pool")]
190 {
191 self.lease.release().await.map_err(|e| {
192 BoxError::BuildError(format!(
193 "Failed to release warm-pool Dockerfile RUN lease: {}",
194 e
195 ))
196 })
197 }
198
199 #[cfg(not(feature = "pool"))]
200 {
201 Ok(())
202 }
203 }
204}
205
206fn run_bind_mount_input_hash(
207 context_dir: &Path,
208 completed_stages: &[(Option<String>, PathBuf)],
209 bind_mounts: &[RunBindMount],
210) -> Option<String> {
211 let mut input = String::new();
212
213 for mount in bind_mounts {
214 if has_parent_component(&mount.source) {
215 return None;
216 }
217
218 let source = if mount.source.is_empty() {
219 "."
220 } else {
221 mount.source.as_str()
222 };
223 let (origin, source_root) = match mount.from.as_deref() {
224 Some(from_ref) => (
225 format!("stage:{from_ref}"),
226 resolve_stage_rootfs(from_ref, completed_stages).ok()?,
227 ),
228 None => ("context".to_string(), context_dir),
229 };
230
231 let source_hash = hash_context_sources(source_root, &[source.to_string()])?;
232 input.push_str(&origin);
233 input.push('\0');
234 input.push_str(source);
235 input.push('\0');
236 input.push_str(&source_hash);
237 input.push('\0');
238
239 if mount.from.is_none() {
240 let dockerignore = context_dir.join(".dockerignore");
241 if let Ok(bytes) = std::fs::read(&dockerignore) {
242 input.push_str(".dockerignore");
243 input.push('\0');
244 input.push_str(&sha256_bytes(&bytes));
245 input.push('\0');
246 }
247 }
248 }
249
250 Some(sha256_bytes(input.as_bytes()))
251}
252
253fn run_cache_mount_input_hash(
254 completed_stages: &[(Option<String>, PathBuf)],
255 cache_mounts: &[RunCacheMount],
256) -> Option<String> {
257 let mut input = String::new();
258 let mut saw_seeded_cache = false;
259
260 for mount in cache_mounts {
261 let Some(from_ref) = mount.from.as_deref() else {
262 continue;
263 };
264 if has_parent_component(&mount.source) {
265 return None;
266 }
267
268 saw_seeded_cache = true;
269 let source = if mount.source.is_empty() {
270 "."
271 } else {
272 mount.source.as_str()
273 };
274 let source_root = resolve_stage_rootfs(from_ref, completed_stages).ok()?;
275 let source_hash = hash_context_sources(source_root, &[source.to_string()])?;
276 input.push_str("cache-seed:");
277 input.push_str(from_ref);
278 input.push('\0');
279 input.push_str(source);
280 input.push('\0');
281 input.push_str(&source_hash);
282 input.push('\0');
283 }
284
285 saw_seeded_cache.then(|| sha256_bytes(input.as_bytes()))
286}
287
288fn run_mount_input_hash(
289 context_dir: &Path,
290 completed_stages: &[(Option<String>, PathBuf)],
291 cache_mounts: &[RunCacheMount],
292 bind_mounts: &[RunBindMount],
293) -> Option<String> {
294 let bind_hash = if bind_mounts.is_empty() {
295 None
296 } else {
297 run_bind_mount_input_hash(context_dir, completed_stages, bind_mounts)
298 };
299 let cache_hash = run_cache_mount_input_hash(completed_stages, cache_mounts);
300
301 match (bind_hash, cache_hash) {
302 (None, None) => None,
303 (Some(hash), None) | (None, Some(hash)) => Some(hash),
304 (Some(bind_hash), Some(cache_hash)) => Some(sha256_bytes(
305 format!("bind\0{bind_hash}\0cache\0{cache_hash}").as_bytes(),
306 )),
307 }
308}
309
310fn has_parent_component(path: &str) -> bool {
311 Path::new(path)
312 .components()
313 .any(|component| matches!(component, std::path::Component::ParentDir))
314}
315
316async fn resolve_run_mount_source_roots(
317 completed_stages: &[(Option<String>, PathBuf)],
318 bind_mounts: &[RunBindMount],
319 cache_mounts: &[RunCacheMount],
320 store: &Arc<ImageStore>,
321 build_dir: &Path,
322 external_from_rootfs: &mut HashMap<String, PathBuf>,
323) -> Result<Option<Vec<(Option<String>, PathBuf)>>> {
324 let mut roots: Option<Vec<(Option<String>, PathBuf)>> = None;
325 let mut external_refs = HashSet::new();
326
327 let mut from_refs: Vec<&str> = Vec::new();
328 from_refs.extend(bind_mounts.iter().filter_map(|mount| mount.from.as_deref()));
329 from_refs.extend(
330 cache_mounts
331 .iter()
332 .filter_map(|mount| mount.from.as_deref()),
333 );
334
335 for from_ref in from_refs {
336 if resolve_stage_rootfs(from_ref, completed_stages).is_ok()
337 || roots
338 .as_deref()
339 .is_some_and(|resolved| resolve_stage_rootfs(from_ref, resolved).is_ok())
340 {
341 continue;
342 }
343
344 if !external_refs.insert(from_ref.to_string()) {
345 continue;
346 }
347
348 let rootfs = resolve_external_from_rootfs(
349 from_ref,
350 "RUN bind mount",
351 store,
352 build_dir,
353 external_from_rootfs,
354 )
355 .await?;
356 roots
357 .get_or_insert_with(|| completed_stages.to_vec())
358 .push((Some(from_ref.to_string()), rootfs));
359 }
360
361 Ok(roots)
362}
363
364pub(super) struct BuildState {
366 pub(super) workdir: String,
368 pub(super) env: Vec<(String, String)>,
370 pub(super) entrypoint: Option<Vec<String>>,
372 pub(super) cmd: Option<Vec<String>>,
374 pub(super) user: Option<String>,
376 pub(super) exposed_ports: Vec<String>,
378 pub(super) labels: HashMap<String, String>,
380 pub(super) layers: Vec<LayerInfo>,
382 pub(super) diff_ids: Vec<String>,
384 pub(super) history: Vec<HistoryEntry>,
386 pub(super) build_args: HashMap<String, String>,
389 pub(super) declared_args: HashSet<String>,
393 pub(super) shell: Vec<String>,
395 pub(super) stop_signal: Option<String>,
397 pub(super) health_check: Option<OciHealthCheck>,
399 pub(super) onbuild: Vec<String>,
401 pub(super) volumes: Vec<String>,
403}
404
405#[derive(Debug, Clone)]
407pub(super) struct HistoryEntry {
408 pub(super) created_by: String,
409 pub(super) empty_layer: bool,
410}
411
412pub use crate::oci::image::OciHealthCheck;
413
414impl BuildState {
415 fn new(build_args: HashMap<String, String>) -> Self {
416 Self {
417 workdir: "/".to_string(),
418 env: Vec::new(),
419 entrypoint: None,
420 cmd: None,
421 user: None,
422 exposed_ports: Vec::new(),
423 labels: HashMap::new(),
424 layers: Vec::new(),
425 diff_ids: Vec::new(),
426 history: Vec::new(),
427 build_args,
428 declared_args: HashSet::new(),
429 shell: vec!["/bin/sh".to_string(), "-c".to_string()],
430 stop_signal: None,
431 health_check: None,
432 onbuild: Vec::new(),
433 volumes: Vec::new(),
434 }
435 }
436
437 fn declared_build_args(&self) -> HashMap<String, String> {
440 self.build_args
441 .iter()
442 .filter(|(name, _)| self.declared_args.contains(*name))
443 .map(|(name, value)| (name.clone(), value.clone()))
444 .collect()
445 }
446
447 fn expansion_vars(&self) -> HashMap<String, String> {
451 let mut vars = self.declared_build_args();
452 for (key, value) in &self.env {
453 vars.insert(key.clone(), value.clone());
454 }
455 vars
456 }
457
458 fn run_env(&self) -> Vec<(String, String)> {
462 let mut vars = self.declared_build_args();
463 for (key, value) in &self.env {
464 vars.insert(key.clone(), value.clone());
465 }
466 let mut pairs = vars.into_iter().collect::<Vec<_>>();
467 pairs.sort_by(|a, b| a.0.cmp(&b.0));
468 pairs
469 }
470
471 fn seed_global_arg(&mut self, name: &str, default: Option<&str>) {
474 self.declared_args.insert(name.to_string());
475 if !self.build_args.contains_key(name) {
476 if let Some(val) = default {
477 self.build_args.insert(name.to_string(), val.to_string());
478 }
479 }
480 }
481}
482
483pub async fn build(config: BuildConfig, store: Arc<ImageStore>) -> Result<BuildResult> {
498 validate_build_config(&config)?;
499
500 let build_dir = tempfile::TempDir::new()
501 .map_err(|e| BoxError::BuildError(format!("Failed to create build directory: {}", e)))?;
502 build_in_workspace(config, store, build_dir.path(), None, None)
503 .await
504 .map(|result| result.output)
505}
506
507pub(super) async fn build_supervised(
509 config: BuildConfig,
510 store: Arc<ImageStore>,
511 workspace: &Path,
512 control: BuildExecutionControl,
513 cache_identity: Option<BuildCacheExportIdentity>,
514) -> Result<SupervisedBuildResult> {
515 validate_build_config(&config)?;
516 control.ensure_active().await?;
517 build_in_workspace(config, store, workspace, Some(control), cache_identity).await
518}
519
520async fn build_in_workspace(
521 config: BuildConfig,
522 store: Arc<ImageStore>,
523 build_dir: &Path,
524 control: Option<BuildExecutionControl>,
525 cache_identity: Option<BuildCacheExportIdentity>,
526) -> Result<SupervisedBuildResult> {
527 let dockerfile = Dockerfile::from_file(&config.dockerfile_path)?;
529
530 let dockerignore = DockerIgnore::load(&config.context_dir);
532
533 if !config.quiet {
534 println!("Building from {}", config.dockerfile_path.display());
535 if !dockerignore.is_empty() {
536 println!("Using .dockerignore");
537 }
538 }
539
540 let stages = split_into_stages(&dockerfile.instructions);
542 let global_args = global_arg_decls(&dockerfile.instructions);
546 let total_stages = stages.len();
547
548 let output_stage_idx = match config.target.as_deref() {
552 Some(target) => stages
553 .iter()
554 .position(|s| s.alias.as_deref() == Some(target))
555 .or_else(|| target.parse::<usize>().ok().filter(|i| *i < total_stages))
556 .ok_or_else(|| {
557 BoxError::BuildError(format!("target build stage '{}' not found", target))
558 })?,
559 None => total_stages - 1,
560 };
561
562 let mut completed_stages: Vec<(Option<String>, PathBuf)> = Vec::new();
564 let mut external_from_rootfs: HashMap<String, PathBuf> = HashMap::new();
567
568 let mut final_state = BuildState::new(config.build_args.clone());
569 let mut final_base_layers: Vec<LayerInfo> = Vec::new();
570 let mut final_base_diff_ids: Vec<String> = Vec::new();
571
572 let total_instructions = dockerfile.instructions.len();
573 let mut global_step = 0;
574 let cache = if config.no_cache {
578 None
579 } else {
580 BuildCache::open()
581 };
582 let mut cache_trace = cache_identity.as_ref().map(|_| BuildCacheTrace::default());
583
584 for (stage_idx, stage) in stages.iter().enumerate() {
585 if let Some(control) = &control {
586 control.ensure_active().await?;
587 }
588 let is_final_stage = stage_idx == output_stage_idx;
589
590 let rootfs_dir = build_dir.join(format!("rootfs_{}", stage_idx));
591 let layers_dir = build_dir.join(format!("layers_{}", stage_idx));
592 std::fs::create_dir_all(&rootfs_dir).map_err(|e| {
593 BoxError::BuildError(format!("Failed to create rootfs directory: {}", e))
594 })?;
595 std::fs::create_dir_all(&layers_dir).map_err(|e| {
596 BoxError::BuildError(format!("Failed to create layers directory: {}", e))
597 })?;
598
599 let mut state = BuildState::new(config.build_args.clone());
600 if stage_idx > 0 {
604 for (name, default) in &global_args {
605 state.seed_global_arg(name, default.as_deref());
606 }
607 }
608 let mut base_layers: Vec<LayerInfo> = Vec::new();
609 let mut base_diff_ids: Vec<String> = Vec::new();
610 let mut chain_key = String::new();
612 let mut cache_valid = true;
614
615 for instruction in &stage.instructions {
616 if let Some(control) = &control {
617 control.ensure_active().await?;
618 }
619 global_step += 1;
620 let step = global_step;
621 validate_instruction_network(instruction, config.network)?;
622 let run_mount_source_roots = if let Instruction::Run {
623 bind_mounts,
624 cache_mounts,
625 ..
626 } = instruction
627 {
628 resolve_run_mount_source_roots(
629 &completed_stages,
630 bind_mounts,
631 cache_mounts,
632 &store,
633 build_dir,
634 &mut external_from_rootfs,
635 )
636 .await?
637 } else {
638 None
639 };
640
641 if !matches!(instruction, Instruction::From { .. }) {
646 let repr = match instruction {
652 Instruction::Env { vars } => {
653 let pairs: Vec<String> = vars
654 .iter()
655 .map(|(k, v)| {
656 format!("{}={}", k, expand_args(v, &state.expansion_vars()))
657 })
658 .collect();
659 format!("ENV {}", pairs.join(" "))
660 }
661 Instruction::Arg { name, default } => {
662 let effective = state
663 .build_args
664 .get(name)
665 .cloned()
666 .or_else(|| default.clone())
667 .unwrap_or_default();
668 format!("ARG {}={}", name, effective)
669 }
670 Instruction::Run { .. } => {
671 run_instruction_cache_repr(instruction, config.network)
672 }
673 other => instruction_to_string(other),
674 };
675 let input_hash = match instruction {
676 Instruction::Copy {
677 src, from: None, ..
678 } => hash_context_sources(&config.context_dir, src),
679 Instruction::Copy {
680 src,
681 from: Some(from_ref),
682 ..
683 } => {
684 resolve_stage_rootfs(from_ref, &completed_stages)
692 .ok()
693 .and_then(|rootfs| hash_context_sources(rootfs, src))
694 }
695 Instruction::Add { src, .. } => hash_context_sources(&config.context_dir, src),
696 Instruction::Run {
697 cache_mounts,
698 bind_mounts,
699 ..
700 } => run_mount_input_hash(
701 &config.context_dir,
702 run_mount_source_roots
703 .as_deref()
704 .unwrap_or(&completed_stages),
705 cache_mounts,
706 bind_mounts,
707 ),
708 _ => None,
709 };
710 chain_key = BuildCache::chain(&chain_key, &repr, input_hash.as_deref());
711 }
712
713 match instruction {
714 Instruction::From { image, alias } => {
715 if !config.quiet {
716 if total_stages > 1 {
717 println!(
718 "Step {}/{}: FROM {} (stage {}/{}{})",
719 step,
720 total_instructions,
721 image,
722 stage_idx + 1,
723 total_stages,
724 alias
725 .as_ref()
726 .map(|a| format!(" as {}", a))
727 .unwrap_or_default()
728 );
729 } else {
730 println!("Step {}/{}: FROM {}", step, total_instructions, image);
731 }
732 }
733 let (layers, diff_ids, base_config) = handle_from(
734 image,
735 &rootfs_dir,
736 &layers_dir,
737 &store,
738 &state.declared_build_args(),
739 )
740 .await?;
741 base_layers = layers;
742 base_diff_ids = diff_ids;
743
744 chain_key = sha256_bytes(base_diff_ids.join(",").as_bytes());
748 cache_valid = true;
749
750 apply_base_config(&mut state, &base_config);
752
753 if !base_config.onbuild.is_empty() && !config.quiet {
755 println!(
756 " Executing {} ONBUILD trigger(s) from base image",
757 base_config.onbuild.len()
758 );
759 }
760 for trigger in &base_config.onbuild {
761 execute_onbuild_trigger(
762 trigger,
763 &mut state,
764 &config,
765 &rootfs_dir,
766 &layers_dir,
767 &base_layers,
768 &completed_stages,
769 )?;
770 }
771
772 state.history.push(HistoryEntry {
773 created_by: format!("FROM {}", image),
774 empty_layer: true,
775 });
776 }
777
778 Instruction::Copy {
779 src,
780 dst,
781 from,
782 chown,
783 } => {
784 let created_by = if let Some(from_ref) = from {
785 format!("COPY --from={} {} {}", from_ref, src.join(" "), dst)
786 } else if let Some(owner) = chown {
787 format!("COPY --chown={} {} {}", owner, src.join(" "), dst)
788 } else {
789 format!("COPY {} {}", src.join(" "), dst)
790 };
791 if let Some(cached) = try_reuse_cached_layer(
792 CachedLayerReuse {
793 cache_valid,
794 cache: cache.as_ref(),
795 chain_key: &chain_key,
796 rootfs_dir: &rootfs_dir,
797 layers_dir: &layers_dir,
798 layer_index: state.layers.len() + base_layers.len(),
799 created_by: &created_by,
800 },
801 &mut state,
802 )? {
803 if let Some(trace) = &mut cache_trace {
804 trace.record(&chain_key, &cached)?;
805 }
806 if !config.quiet {
807 println!(
808 "Step {}/{}: {} (CACHED)",
809 step, total_instructions, created_by
810 );
811 }
812 continue;
813 }
814 cache_valid = false;
815
816 if let Some(from_ref) = from {
817 if !config.quiet {
818 println!(
819 "Step {}/{}: COPY --from={} {} {}",
820 step,
821 total_instructions,
822 from_ref,
823 src.join(" "),
824 dst
825 );
826 }
827 let from_rootfs: PathBuf =
831 match resolve_stage_rootfs(from_ref, &completed_stages) {
832 Ok(stage_rootfs) => stage_rootfs.to_path_buf(),
833 Err(_) => {
834 resolve_external_from_rootfs(
835 from_ref,
836 "COPY --from",
837 &store,
838 build_dir,
839 &mut external_from_rootfs,
840 )
841 .await?
842 }
843 };
844 let layer_info = handle_copy(
847 src,
848 dst,
849 chown.as_deref(),
850 &from_rootfs,
851 &rootfs_dir,
852 &layers_dir,
853 &state.workdir,
854 state.layers.len() + base_layers.len(),
855 None,
856 )?;
857 let diff_id = compute_diff_id(&layer_info.path)?;
858 store_cache_entry(
859 cache.as_ref(),
860 cache_trace.as_mut(),
861 &chain_key,
862 &layer_info,
863 &diff_id,
864 )?;
865 state.diff_ids.push(diff_id);
866 state.layers.push(layer_info);
867 state.history.push(HistoryEntry {
868 created_by: format!(
869 "COPY --from={} {} {}",
870 from_ref,
871 src.join(" "),
872 dst
873 ),
874 empty_layer: false,
875 });
876 } else {
877 if !config.quiet {
878 println!(
879 "Step {}/{}: COPY {} {}",
880 step,
881 total_instructions,
882 src.join(" "),
883 dst
884 );
885 }
886 let layer_info = handle_copy(
887 src,
888 dst,
889 chown.as_deref(),
890 &config.context_dir,
891 &rootfs_dir,
892 &layers_dir,
893 &state.workdir,
894 state.layers.len() + base_layers.len(),
895 Some(&dockerignore),
896 )?;
897 let diff_id = compute_diff_id(&layer_info.path)?;
898 store_cache_entry(
899 cache.as_ref(),
900 cache_trace.as_mut(),
901 &chain_key,
902 &layer_info,
903 &diff_id,
904 )?;
905 state.diff_ids.push(diff_id);
906 state.layers.push(layer_info);
907 state.history.push(HistoryEntry {
908 created_by: format!("COPY {} {}", src.join(" "), dst),
909 empty_layer: false,
910 });
911 }
912 }
913
914 Instruction::Add { src, dst, chown } => {
915 let created_by = format!("ADD {} {}", src.join(" "), dst);
916 if let Some(cached) = try_reuse_cached_layer(
917 CachedLayerReuse {
918 cache_valid,
919 cache: cache.as_ref(),
920 chain_key: &chain_key,
921 rootfs_dir: &rootfs_dir,
922 layers_dir: &layers_dir,
923 layer_index: state.layers.len() + base_layers.len(),
924 created_by: &created_by,
925 },
926 &mut state,
927 )? {
928 if let Some(trace) = &mut cache_trace {
929 trace.record(&chain_key, &cached)?;
930 }
931 if !config.quiet {
932 println!(
933 "Step {}/{}: {} (CACHED)",
934 step, total_instructions, created_by
935 );
936 }
937 continue;
938 }
939 cache_valid = false;
940
941 if !config.quiet {
942 println!(
943 "Step {}/{}: ADD {} {}",
944 step,
945 total_instructions,
946 src.join(" "),
947 dst
948 );
949 }
950 let layer_info = handle_add(
951 src,
952 dst,
953 chown.as_deref(),
954 &config.context_dir,
955 &rootfs_dir,
956 &layers_dir,
957 &state.workdir,
958 state.layers.len() + base_layers.len(),
959 Some(&dockerignore),
960 )?;
961 let diff_id = compute_diff_id(&layer_info.path)?;
962 store_cache_entry(
963 cache.as_ref(),
964 cache_trace.as_mut(),
965 &chain_key,
966 &layer_info,
967 &diff_id,
968 )?;
969 state.diff_ids.push(diff_id);
970 state.layers.push(layer_info);
971 state.history.push(HistoryEntry {
972 created_by: format!("ADD {} {}", src.join(" "), dst),
973 empty_layer: false,
974 });
975 }
976
977 Instruction::Run {
978 command,
979 cache_mounts,
980 bind_mounts,
981 tmpfs_mounts,
982 } => {
983 let created_by = instruction_to_string(instruction);
984 if let Some(cached) = try_reuse_cached_layer(
985 CachedLayerReuse {
986 cache_valid,
987 cache: cache.as_ref(),
988 chain_key: &chain_key,
989 rootfs_dir: &rootfs_dir,
990 layers_dir: &layers_dir,
991 layer_index: state.layers.len() + base_layers.len(),
992 created_by: &created_by,
993 },
994 &mut state,
995 )? {
996 if let Some(trace) = &mut cache_trace {
997 trace.record(&chain_key, &cached)?;
998 }
999 if !config.quiet {
1000 println!(
1001 "Step {}/{}: {} (CACHED)",
1002 step, total_instructions, created_by
1003 );
1004 }
1005 continue;
1006 }
1007 cache_valid = false;
1008
1009 if !config.quiet {
1010 println!("Step {}/{}: {}", step, total_instructions, created_by);
1011 }
1012 let layer_opt = if let Some(pool_config) = &config.run_pool {
1013 let session =
1017 BuildRunPoolSession::acquire(pool_config, &rootfs_dir).await?;
1018 handle_run_with_pool(
1019 command,
1020 cache_mounts,
1021 bind_mounts,
1022 tmpfs_mounts,
1023 &config.context_dir,
1024 run_mount_source_roots
1025 .as_deref()
1026 .unwrap_or(&completed_stages),
1027 &rootfs_dir,
1028 &layers_dir,
1029 &state.workdir,
1030 &state.run_env(),
1031 &state.shell,
1032 state.user.as_deref(),
1033 state.layers.len() + base_layers.len(),
1034 config.quiet,
1035 session,
1036 Some(&dockerignore),
1037 )
1038 .await?
1039 } else {
1040 handle_run(
1041 command,
1042 cache_mounts,
1043 bind_mounts,
1044 tmpfs_mounts,
1045 config.network,
1046 &config.context_dir,
1047 run_mount_source_roots
1048 .as_deref()
1049 .unwrap_or(&completed_stages),
1050 &rootfs_dir,
1051 &layers_dir,
1052 &state.workdir,
1053 &state.run_env(),
1054 &state.shell,
1055 state.layers.len() + base_layers.len(),
1056 config.quiet,
1057 Some(&dockerignore),
1058 control.as_ref(),
1059 )
1060 .await?
1061 };
1062 if let Some(layer_info) = layer_opt {
1063 let diff_id = compute_diff_id(&layer_info.path)?;
1064 store_cache_entry(
1065 cache.as_ref(),
1066 cache_trace.as_mut(),
1067 &chain_key,
1068 &layer_info,
1069 &diff_id,
1070 )?;
1071 state.diff_ids.push(diff_id);
1072 state.layers.push(layer_info);
1073 state.history.push(HistoryEntry {
1074 created_by: created_by.clone(),
1075 empty_layer: false,
1076 });
1077 } else {
1078 state.history.push(HistoryEntry {
1079 created_by: created_by.clone(),
1080 empty_layer: true,
1081 });
1082 }
1083 }
1084
1085 Instruction::Workdir { path } => {
1086 if !config.quiet {
1087 println!("Step {}/{}: WORKDIR {}", step, total_instructions, path);
1088 }
1089 let expanded_path = expand_args(path, &state.expansion_vars());
1091 state.workdir = resolve_path(&state.workdir, &expanded_path);
1092 crate::oci::rootfs::ensure_guest_directory(
1093 &rootfs_dir,
1094 state.workdir.trim_start_matches('/'),
1095 )?;
1096 state.history.push(HistoryEntry {
1097 created_by: format!("WORKDIR {}", path),
1098 empty_layer: true,
1099 });
1100 }
1101
1102 Instruction::Env { vars } => {
1103 let display: Vec<String> =
1104 vars.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
1105 let display = display.join(" ");
1106 if !config.quiet {
1107 println!("Step {}/{}: ENV {}", step, total_instructions, display);
1108 }
1109 for (key, value) in vars {
1110 let expanded_value = expand_args(value, &state.expansion_vars());
1113 if let Some(existing) = state.env.iter_mut().find(|(k, _)| k == key) {
1114 existing.1 = expanded_value;
1115 } else {
1116 state.env.push((key.clone(), expanded_value));
1117 }
1118 }
1119 state.history.push(HistoryEntry {
1120 created_by: format!("ENV {}", display),
1121 empty_layer: true,
1122 });
1123 }
1124
1125 Instruction::Entrypoint { exec } => {
1126 if !config.quiet {
1127 println!(
1128 "Step {}/{}: ENTRYPOINT {:?}",
1129 step, total_instructions, exec
1130 );
1131 }
1132 state.entrypoint = Some(exec.clone());
1133 state.history.push(HistoryEntry {
1134 created_by: format!("ENTRYPOINT {:?}", exec),
1135 empty_layer: true,
1136 });
1137 }
1138
1139 Instruction::Cmd { exec } => {
1140 if !config.quiet {
1141 println!("Step {}/{}: CMD {:?}", step, total_instructions, exec);
1142 }
1143 state.cmd = Some(exec.clone());
1144 state.history.push(HistoryEntry {
1145 created_by: format!("CMD {:?}", exec),
1146 empty_layer: true,
1147 });
1148 }
1149
1150 Instruction::Expose { ports } => {
1151 let joined = ports.join(" ");
1152 if !config.quiet {
1153 println!("Step {}/{}: EXPOSE {}", step, total_instructions, joined);
1154 }
1155 for port in ports {
1156 if !state.exposed_ports.contains(port) {
1157 state.exposed_ports.push(port.clone());
1158 }
1159 }
1160 state.history.push(HistoryEntry {
1161 created_by: format!("EXPOSE {}", joined),
1162 empty_layer: true,
1163 });
1164 }
1165
1166 Instruction::Label { pairs } => {
1167 let joined = pairs
1168 .iter()
1169 .map(|(k, v)| format!("{}={}", k, v))
1170 .collect::<Vec<_>>()
1171 .join(" ");
1172 if !config.quiet {
1173 println!("Step {}/{}: LABEL {}", step, total_instructions, joined);
1174 }
1175 for (key, value) in pairs {
1176 state.labels.insert(key.clone(), value.clone());
1177 }
1178 state.history.push(HistoryEntry {
1179 created_by: format!("LABEL {}", joined),
1180 empty_layer: true,
1181 });
1182 }
1183
1184 Instruction::User { user } => {
1185 if !config.quiet {
1186 println!("Step {}/{}: USER {}", step, total_instructions, user);
1187 }
1188 state.user = Some(user.clone());
1189 state.history.push(HistoryEntry {
1190 created_by: format!("USER {}", user),
1191 empty_layer: true,
1192 });
1193 }
1194
1195 Instruction::Arg { name, default } => {
1196 if !config.quiet {
1197 println!("Step {}/{}: ARG {}", step, total_instructions, name);
1198 }
1199 state.declared_args.insert(name.clone());
1200 if !state.build_args.contains_key(name) {
1201 if let Some(val) = default {
1202 state.build_args.insert(name.clone(), val.clone());
1203 }
1204 }
1205 state.history.push(HistoryEntry {
1206 created_by: format!("ARG {}", name),
1207 empty_layer: true,
1208 });
1209 }
1210
1211 Instruction::Shell { exec } => {
1212 if !config.quiet {
1213 println!("Step {}/{}: SHELL {:?}", step, total_instructions, exec);
1214 }
1215 state.shell = exec.clone();
1216 state.history.push(HistoryEntry {
1217 created_by: format!("SHELL {:?}", exec),
1218 empty_layer: true,
1219 });
1220 }
1221
1222 Instruction::StopSignal { signal } => {
1223 if !config.quiet {
1224 println!(
1225 "Step {}/{}: STOPSIGNAL {}",
1226 step, total_instructions, signal
1227 );
1228 }
1229 state.stop_signal = Some(signal.clone());
1230 state.history.push(HistoryEntry {
1231 created_by: format!("STOPSIGNAL {}", signal),
1232 empty_layer: true,
1233 });
1234 }
1235
1236 Instruction::HealthCheck {
1237 cmd,
1238 interval,
1239 timeout,
1240 retries,
1241 start_period,
1242 } => {
1243 if !config.quiet {
1244 if cmd.is_some() {
1245 println!("Step {}/{}: HEALTHCHECK CMD ...", step, total_instructions);
1246 } else {
1247 println!("Step {}/{}: HEALTHCHECK NONE", step, total_instructions);
1248 }
1249 }
1250 state.health_check = cmd.as_ref().map(|c| OciHealthCheck {
1251 test: c.clone(),
1252 interval: *interval,
1253 timeout: *timeout,
1254 retries: *retries,
1255 start_period: *start_period,
1256 });
1257 state.history.push(HistoryEntry {
1258 created_by: if cmd.is_some() {
1259 "HEALTHCHECK CMD ...".to_string()
1260 } else {
1261 "HEALTHCHECK NONE".to_string()
1262 },
1263 empty_layer: true,
1264 });
1265 }
1266
1267 Instruction::OnBuild { instruction } => {
1268 let trigger = format!("{:?}", instruction);
1269 if !config.quiet {
1270 println!("Step {}/{}: ONBUILD {}", step, total_instructions, trigger);
1271 }
1272 state.onbuild.push(instruction_to_string(instruction));
1274 state.history.push(HistoryEntry {
1275 created_by: format!("ONBUILD {}", instruction_to_string(instruction)),
1276 empty_layer: true,
1277 });
1278 }
1279
1280 Instruction::Volume { paths } => {
1281 if !config.quiet {
1282 println!(
1283 "Step {}/{}: VOLUME {}",
1284 step,
1285 total_instructions,
1286 paths.join(" ")
1287 );
1288 }
1289 for p in paths {
1290 if !state.volumes.contains(p) {
1291 state.volumes.push(p.clone());
1292 }
1293 }
1294 for p in paths {
1296 crate::oci::rootfs::ensure_guest_directory(
1297 &rootfs_dir,
1298 p.trim_start_matches('/'),
1299 )?;
1300 }
1301 state.history.push(HistoryEntry {
1302 created_by: format!("VOLUME {}", paths.join(" ")),
1303 empty_layer: true,
1304 });
1305 }
1306 }
1307 }
1308
1309 completed_stages.push((stage.alias.clone(), rootfs_dir.clone()));
1311
1312 if is_final_stage {
1313 final_state = state;
1314 final_base_layers = base_layers;
1315 final_base_diff_ids = base_diff_ids;
1316 break;
1318 }
1319 }
1320
1321 let reference = config
1323 .tag
1324 .clone()
1325 .unwrap_or_else(|| "a3s-build:latest".to_string());
1326
1327 let final_layers_dir = build_dir.join(format!("layers_{}", output_stage_idx));
1328
1329 let target_platform = config
1331 .platforms
1332 .first()
1333 .cloned()
1334 .unwrap_or_else(default_target_platform);
1335
1336 if let Some(control) = &control {
1337 control.ensure_active().await?;
1338 }
1339 let staged_cache = match cache_identity.as_ref() {
1340 Some(identity) => {
1341 let cache = cache.as_ref().ok_or_else(|| {
1342 BoxError::BuildError(
1343 "content-addressed build cache could not be opened for export".to_string(),
1344 )
1345 })?;
1346 let trace = cache_trace.as_ref().ok_or_else(|| {
1347 BoxError::BuildError(
1348 "content-addressed build cache export lost its native trace".to_string(),
1349 )
1350 })?;
1351 Some(cache.stage_export(trace, identity, &build_dir.join("_cache_export"))?)
1352 }
1353 None => None,
1354 };
1355 let result = assemble_image(
1356 &reference,
1357 &final_state,
1358 &final_base_layers,
1359 &final_base_diff_ids,
1360 &final_layers_dir,
1361 &store,
1362 &target_platform,
1363 control.as_ref(),
1364 staged_cache,
1365 )
1366 .await?;
1367
1368 if !config.quiet {
1369 println!(
1370 "Successfully built {} ({} layers, {}, {})",
1371 reference,
1372 result.output.layer_count,
1373 format_size(result.output.size),
1374 target_platform,
1375 );
1376 }
1377
1378 if let Some(ref m) = config.metrics {
1379 m.image_build_total.inc();
1380 }
1381
1382 Ok(result)
1383}
1384
1385fn store_cache_entry(
1390 cache: Option<&BuildCache>,
1391 trace: Option<&mut BuildCacheTrace>,
1392 chain_key: &str,
1393 layer: &LayerInfo,
1394 diff_id: &str,
1395) -> Result<()> {
1396 let Some(cache) = cache else {
1397 if trace.is_some() {
1398 return Err(BoxError::BuildError(
1399 "content-addressed build cache could not be opened".to_string(),
1400 ));
1401 }
1402 return Ok(());
1403 };
1404 cache.store(chain_key, layer, diff_id);
1405 if let Some(trace) = trace {
1406 let cached = cache.lookup(chain_key).ok_or_else(|| {
1407 BoxError::BuildError(format!(
1408 "content-addressed build cache did not retain chain key sha256:{chain_key}"
1409 ))
1410 })?;
1411 trace.record(chain_key, &cached)?;
1412 }
1413 Ok(())
1414}
1415
1416struct CachedLayerReuse<'a> {
1425 cache_valid: bool,
1426 cache: Option<&'a BuildCache>,
1427 chain_key: &'a str,
1428 rootfs_dir: &'a Path,
1429 layers_dir: &'a Path,
1430 layer_index: usize,
1431 created_by: &'a str,
1432}
1433
1434fn try_reuse_cached_layer(
1435 request: CachedLayerReuse<'_>,
1436 state: &mut BuildState,
1437) -> Result<Option<CachedLayer>> {
1438 if !request.cache_valid {
1439 return Ok(None);
1440 }
1441 let Some(cached) = request.cache.and_then(|c| c.lookup(request.chain_key)) else {
1442 return Ok(None);
1443 };
1444
1445 let local_layer = request.layers_dir.join(format!(
1446 "cached_{}_{}.tar.gz",
1447 request.layer_index, cached.digest
1448 ));
1449 if let Err(error) = std::fs::copy(&cached.blob_path, &local_layer) {
1450 tracing::warn!(
1451 key = %request.chain_key,
1452 source = %cached.blob_path.display(),
1453 error = %error,
1454 "Build cache blob disappeared before it could be materialized; rebuilding instruction"
1455 );
1456 return Ok(None);
1457 }
1458
1459 extract_layer(&local_layer, request.rootfs_dir)?;
1461 let local_size = std::fs::metadata(&local_layer)
1462 .map(|metadata| metadata.len())
1463 .unwrap_or(cached.size);
1464
1465 state.layers.push(LayerInfo {
1466 path: local_layer,
1467 digest: cached.digest.clone(),
1468 size: local_size,
1469 });
1470 state.diff_ids.push(cached.diff_id.clone());
1471 state.history.push(HistoryEntry {
1472 created_by: request.created_by.to_string(),
1473 empty_layer: false,
1474 });
1475 Ok(Some(cached))
1476}
1477
1478async fn handle_from(
1482 image: &str,
1483 rootfs_dir: &Path,
1484 _layers_dir: &Path,
1485 store: &Arc<ImageStore>,
1486 build_args: &HashMap<String, String>,
1487) -> Result<(Vec<LayerInfo>, Vec<String>, OciImageConfig)> {
1488 let image_ref = expand_args(image, build_args);
1489 if image_ref == "scratch" {
1490 return Ok((Vec::new(), Vec::new(), scratch_config()));
1491 }
1492
1493 let puller = ImagePuller::new(store.clone(), RegistryAuth::from_env());
1495 let oci_image = puller.pull(&image_ref).await?;
1496
1497 for layer_path in oci_image.layer_paths() {
1499 extract_layer(layer_path, rootfs_dir)?;
1500 }
1501
1502 let mut base_layers = Vec::new();
1504 let mut base_diff_ids = Vec::new();
1505
1506 for layer_path in oci_image.layer_paths() {
1507 let digest = sha256_file(layer_path)?;
1508 let size = std::fs::metadata(layer_path).map(|m| m.len()).unwrap_or(0);
1509
1510 let diff_id = compute_diff_id(layer_path)?;
1512 base_diff_ids.push(diff_id);
1513
1514 base_layers.push(LayerInfo {
1515 path: layer_path.to_path_buf(),
1516 digest,
1517 size,
1518 });
1519 }
1520
1521 let config = oci_image.config().clone();
1522 Ok((base_layers, base_diff_ids, config))
1523}
1524
1525async fn resolve_external_from_rootfs(
1529 image_ref: &str,
1530 operation: &str,
1531 store: &Arc<ImageStore>,
1532 build_dir: &Path,
1533 cache: &mut HashMap<String, PathBuf>,
1534) -> Result<PathBuf> {
1535 if let Some(dir) = cache.get(image_ref) {
1536 return Ok(dir.clone());
1537 }
1538
1539 let dir = build_dir.join(format!("copyfrom_{}", cache.len()));
1540 std::fs::create_dir_all(&dir).map_err(|e| {
1541 BoxError::BuildError(format!(
1542 "Failed to create {operation} image rootfs {}: {}",
1543 dir.display(),
1544 e
1545 ))
1546 })?;
1547
1548 let puller = ImagePuller::new(store.clone(), RegistryAuth::from_env());
1549 let oci_image = puller.pull(image_ref).await.map_err(|e| {
1550 BoxError::BuildError(format!(
1551 "{operation} from={}: not a build stage and could not be pulled as an image: {}",
1552 image_ref, e
1553 ))
1554 })?;
1555 for layer_path in oci_image.layer_paths() {
1556 extract_layer(layer_path, &dir)?;
1557 }
1558
1559 cache.insert(image_ref.to_string(), dir.clone());
1560 Ok(dir)
1561}
1562
1563fn validate_build_config(config: &BuildConfig) -> Result<()> {
1564 if config.platforms.len() > 1 {
1565 return Err(BoxError::BuildError(
1566 "Multi-platform builds are not implemented yet; pass a single target platform"
1567 .to_string(),
1568 ));
1569 }
1570
1571 for platform in &config.platforms {
1572 if platform.os != "linux" {
1573 return Err(BoxError::BuildError(format!(
1574 "Only linux target platforms are supported for image builds, got {}",
1575 platform
1576 )));
1577 }
1578 }
1579
1580 if config.network == BuildNetworkPolicy::None && config.run_pool.is_some() {
1581 return Err(BoxError::BuildError(
1582 "network-none builds cannot use a warm RUN pool until the pool provides a generation-bound isolated network namespace"
1583 .to_string(),
1584 ));
1585 }
1586
1587 Ok(())
1588}
1589
1590fn validate_instruction_network(
1591 instruction: &Instruction,
1592 network: BuildNetworkPolicy,
1593) -> Result<()> {
1594 if network != BuildNetworkPolicy::None {
1595 return Ok(());
1596 }
1597 if let Instruction::Add { src, .. } = instruction {
1598 if src
1599 .iter()
1600 .any(|source| source.starts_with("http://") || source.starts_with("https://"))
1601 {
1602 return Err(BoxError::BuildError(
1603 "network-none builds reject remote URL ADD; materialize the input as a content-addressed build-context artifact"
1604 .to_string(),
1605 ));
1606 }
1607 }
1608 Ok(())
1609}
1610
1611fn run_instruction_cache_repr(instruction: &Instruction, network: BuildNetworkPolicy) -> String {
1612 format!(
1613 "{}\n#a3s.box.build.network={}",
1614 instruction_to_string(instruction),
1615 network.as_acl()
1616 )
1617}
1618
1619fn default_target_platform() -> Platform {
1620 let host = Platform::host();
1621 Platform::new("linux", host.architecture)
1622}
1623
1624fn scratch_config() -> OciImageConfig {
1625 OciImageConfig {
1626 entrypoint: None,
1627 cmd: None,
1628 env: Vec::new(),
1629 working_dir: None,
1630 user: None,
1631 exposed_ports: Vec::new(),
1632 labels: HashMap::new(),
1633 volumes: Vec::new(),
1634 stop_signal: None,
1635 health_check: None,
1636 onbuild: Vec::new(),
1637 }
1638}
1639
1640#[allow(clippy::too_many_arguments)]
1642async fn assemble_image(
1643 reference: &str,
1644 state: &BuildState,
1645 base_layers: &[LayerInfo],
1646 base_diff_ids: &[String],
1647 layers_dir: &Path,
1648 store: &Arc<ImageStore>,
1649 target_platform: &Platform,
1650 control: Option<&BuildExecutionControl>,
1651 staged_cache: Option<RecordedBuildCache>,
1652) -> Result<SupervisedBuildResult> {
1653 let output_dir = layers_dir.join("_output");
1655 let blobs_dir = output_dir.join("blobs").join("sha256");
1656 std::fs::create_dir_all(&blobs_dir)
1657 .map_err(|e| BoxError::BuildError(format!("Failed to create output blobs dir: {}", e)))?;
1658
1659 let mut all_layer_descriptors = Vec::new();
1661 let mut all_diff_ids: Vec<String> = base_diff_ids.to_vec();
1662
1663 for layer in base_layers {
1665 let blob_path = blobs_dir.join(&layer.digest);
1666 if !blob_path.exists() {
1667 copy_layer_blob(layer, &blob_path, "base layer")?;
1668 }
1669 all_layer_descriptors.push(serde_json::json!({
1670 "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
1671 "digest": layer.prefixed_digest(),
1672 "size": layer.size
1673 }));
1674 }
1675
1676 for (i, layer) in state.layers.iter().enumerate() {
1678 let blob_path = blobs_dir.join(&layer.digest);
1679 if !blob_path.exists() {
1680 copy_layer_blob(layer, &blob_path, &format!("layer {i}"))?;
1681 }
1682 all_layer_descriptors.push(serde_json::json!({
1683 "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
1684 "digest": layer.prefixed_digest(),
1685 "size": layer.size
1686 }));
1687 }
1688
1689 all_diff_ids.extend(state.diff_ids.iter().cloned());
1691
1692 let arch = target_platform.oci_arch();
1694
1695 let env_list: Vec<String> = state
1696 .env
1697 .iter()
1698 .map(|(k, v)| format!("{}={}", k, v))
1699 .collect();
1700
1701 let mut config_obj = serde_json::json!({
1702 "architecture": arch,
1703 "os": "linux",
1704 "created": REPRODUCIBLE_OCI_CREATED_AT,
1705 "config": {},
1706 "rootfs": {
1707 "type": "layers",
1708 "diff_ids": all_diff_ids.iter()
1709 .map(|d| format!("sha256:{}", d))
1710 .collect::<Vec<_>>()
1711 },
1712 "history": state.history.iter().map(|h| {
1713 let mut entry = serde_json::json!({
1714 "created": REPRODUCIBLE_OCI_CREATED_AT,
1715 "created_by": h.created_by
1716 });
1717 if h.empty_layer {
1718 entry["empty_layer"] = serde_json::json!(true);
1719 }
1720 entry
1721 }).collect::<Vec<_>>()
1722 });
1723 if let Some(variant) = &target_platform.variant {
1724 config_obj["variant"] = serde_json::json!(variant);
1725 }
1726
1727 let config_section = config_obj["config"].as_object_mut().unwrap();
1729 if !env_list.is_empty() {
1730 config_section.insert("Env".to_string(), serde_json::json!(env_list));
1731 }
1732 if let Some(ref ep) = state.entrypoint {
1733 config_section.insert("Entrypoint".to_string(), serde_json::json!(ep));
1734 }
1735 if let Some(ref cmd) = state.cmd {
1736 config_section.insert("Cmd".to_string(), serde_json::json!(cmd));
1737 }
1738 if state.workdir != "/" {
1739 config_section.insert("WorkingDir".to_string(), serde_json::json!(state.workdir));
1740 }
1741 if let Some(ref user) = state.user {
1742 config_section.insert("User".to_string(), serde_json::json!(user));
1743 }
1744 if !state.exposed_ports.is_empty() {
1745 let ports: HashMap<String, serde_json::Value> = state
1746 .exposed_ports
1747 .iter()
1748 .map(|p| (p.clone(), serde_json::json!({})))
1749 .collect();
1750 config_section.insert("ExposedPorts".to_string(), serde_json::json!(ports));
1751 }
1752 if !state.labels.is_empty() {
1753 config_section.insert("Labels".to_string(), serde_json::json!(state.labels));
1754 }
1755 if let Some(ref sig) = state.stop_signal {
1756 config_section.insert("StopSignal".to_string(), serde_json::json!(sig));
1757 }
1758 if let Some(ref hc) = state.health_check {
1759 let mut hc_obj = serde_json::json!({
1760 "Test": hc.test,
1761 });
1762 if let Some(interval) = hc.interval {
1763 hc_obj["Interval"] = serde_json::json!(interval * 1_000_000_000);
1765 }
1766 if let Some(timeout) = hc.timeout {
1767 hc_obj["Timeout"] = serde_json::json!(timeout * 1_000_000_000);
1768 }
1769 if let Some(retries) = hc.retries {
1770 hc_obj["Retries"] = serde_json::json!(retries);
1771 }
1772 if let Some(start_period) = hc.start_period {
1773 hc_obj["StartPeriod"] = serde_json::json!(start_period * 1_000_000_000);
1774 }
1775 config_section.insert("Healthcheck".to_string(), hc_obj);
1776 }
1777 if !state.onbuild.is_empty() {
1778 config_section.insert("OnBuild".to_string(), serde_json::json!(state.onbuild));
1779 }
1780 if !state.volumes.is_empty() {
1781 let vols: HashMap<String, serde_json::Value> = state
1782 .volumes
1783 .iter()
1784 .map(|v| (v.clone(), serde_json::json!({})))
1785 .collect();
1786 config_section.insert("Volumes".to_string(), serde_json::json!(vols));
1787 }
1788
1789 let config_bytes = serde_json::to_vec_pretty(&config_obj)?;
1791 let config_digest = sha256_bytes(&config_bytes);
1792 std::fs::write(blobs_dir.join(&config_digest), &config_bytes)
1793 .map_err(|e| BoxError::BuildError(format!("Failed to write config blob: {}", e)))?;
1794
1795 let manifest = serde_json::json!({
1797 "schemaVersion": 2,
1798 "mediaType": OCI_IMAGE_MANIFEST_MEDIA_TYPE,
1799 "config": {
1800 "mediaType": "application/vnd.oci.image.config.v1+json",
1801 "digest": format!("sha256:{}", config_digest),
1802 "size": config_bytes.len()
1803 },
1804 "layers": all_layer_descriptors
1805 });
1806
1807 let manifest_bytes = serde_json::to_vec_pretty(&manifest)?;
1808 let manifest_digest = sha256_bytes(&manifest_bytes);
1809 std::fs::write(blobs_dir.join(&manifest_digest), &manifest_bytes)
1810 .map_err(|e| BoxError::BuildError(format!("Failed to write manifest blob: {}", e)))?;
1811
1812 let mut platform_obj = serde_json::json!({
1814 "os": target_platform.os,
1815 "architecture": target_platform.architecture
1816 });
1817 if let Some(ref variant) = target_platform.variant {
1818 platform_obj["variant"] = serde_json::json!(variant);
1819 }
1820
1821 let index = serde_json::json!({
1822 "schemaVersion": 2,
1823 "mediaType": "application/vnd.oci.image.index.v1+json",
1824 "manifests": [{
1825 "mediaType": OCI_IMAGE_MANIFEST_MEDIA_TYPE,
1826 "digest": format!("sha256:{}", manifest_digest),
1827 "size": manifest_bytes.len(),
1828 "platform": platform_obj
1829 }]
1830 });
1831 std::fs::write(
1832 output_dir.join("index.json"),
1833 serde_json::to_string_pretty(&index)?,
1834 )
1835 .map_err(|e| BoxError::BuildError(format!("Failed to write index.json: {}", e)))?;
1836
1837 std::fs::write(
1839 output_dir.join("oci-layout"),
1840 r#"{"imageLayoutVersion":"1.0.0"}"#,
1841 )
1842 .map_err(|e| BoxError::BuildError(format!("Failed to write oci-layout: {}", e)))?;
1843
1844 let digest_str = format!("sha256:{}", manifest_digest);
1846 let _commit_permit = match control {
1847 Some(control) => Some(control.acquire_image_commit_permit().await?),
1848 None => None,
1849 };
1850 let cache = match staged_cache {
1851 Some(staged) => {
1852 let control = control.ok_or_else(|| {
1853 BoxError::BuildError(
1854 "native cache export requires the recorded-build journal".to_string(),
1855 )
1856 })?;
1857 Some(control.publish_cache_export(staged).await?)
1858 }
1859 None => None,
1860 };
1861 let output =
1862 publish_single_build_output(reference, &digest_str, &output_dir, store, target_platform)
1863 .await?;
1864 Ok(SupervisedBuildResult { output, cache })
1865}
1866
1867fn copy_layer_blob(layer: &LayerInfo, blob_path: &Path, label: &str) -> Result<()> {
1868 if !layer.path.exists() {
1869 return Err(BoxError::BuildError(format!(
1870 "Failed to copy {label}: source layer {} for digest {} does not exist",
1871 layer.path.display(),
1872 layer.prefixed_digest()
1873 )));
1874 }
1875
1876 std::fs::copy(&layer.path, blob_path).map_err(|e| {
1877 BoxError::BuildError(format!(
1878 "Failed to copy {label} from {} to {} (digest {}): {}",
1879 layer.path.display(),
1880 blob_path.display(),
1881 layer.prefixed_digest(),
1882 e
1883 ))
1884 })?;
1885 Ok(())
1886}