Skip to main content

a3s_box_runtime/oci/build/engine/
mod.rs

1//! Build engine for constructing OCI images from Dockerfiles.
2//!
3//! Orchestrates the build process: parses the Dockerfile, pulls the base image,
4//! executes each instruction, creates layers, and assembles the final OCI image.
5
6use 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/// Configuration for a build operation.
38#[derive(Debug, Clone)]
39pub struct BuildConfig {
40    /// Path to the build context directory
41    pub context_dir: PathBuf,
42    /// Path to the Dockerfile (relative to context or absolute)
43    pub dockerfile_path: PathBuf,
44    /// Image tag (e.g., "myimage:latest")
45    pub tag: Option<String>,
46    /// Build arguments (ARG overrides)
47    pub build_args: HashMap<String, String>,
48    /// Suppress build output
49    pub quiet: bool,
50    /// Target platforms for multi-platform builds.
51    /// Empty means build for the host platform only.
52    pub platforms: Vec<Platform>,
53    /// Build only up to this stage (`--target`), by alias or numeric index.
54    /// `None` builds the final stage.
55    pub target: Option<String>,
56    /// Disable the layer build cache (`--no-cache`): every layer is rebuilt.
57    pub no_cache: bool,
58    /// Prometheus metrics (optional).
59    pub metrics: Option<crate::prom::RuntimeMetrics>,
60    /// Execute Dockerfile RUN instructions through a warm-pool daemon lease.
61    pub run_pool: Option<BuildRunPoolConfig>,
62}
63
64/// Configuration for executing Dockerfile RUN instructions in a warm-pool VM.
65#[derive(Debug, Clone)]
66pub struct BuildRunPoolConfig {
67    /// Pool daemon Unix socket.
68    pub socket: String,
69    /// Helper VM image. `None` uses the daemon's default image.
70    pub image: Option<String>,
71    /// Helper VM vCPU count for lazily-created pools.
72    pub vcpus: u32,
73    /// Helper VM memory in MiB for lazily-created pools.
74    pub memory_mb: u32,
75    /// Guest path where the stage rootfs is mounted.
76    pub guest_rootfs: String,
77    /// RUN exec timeout in nanoseconds.
78    pub timeout_ns: u64,
79    /// Persistent cache directory for `RUN --mount=type=cache`.
80    pub run_cache_dir: PathBuf,
81}
82
83/// Result of a successful build.
84#[derive(Debug)]
85pub struct BuildResult {
86    /// Image reference stored in the image store
87    pub reference: String,
88    /// Content digest
89    pub digest: String,
90    /// Total image size in bytes
91    pub size: u64,
92    /// Number of layers
93    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
325/// Mutable state accumulated during the build.
326pub(super) struct BuildState {
327    /// Working directory inside the image
328    pub(super) workdir: String,
329    /// Environment variables
330    pub(super) env: Vec<(String, String)>,
331    /// Entrypoint
332    pub(super) entrypoint: Option<Vec<String>>,
333    /// Default command
334    pub(super) cmd: Option<Vec<String>>,
335    /// User
336    pub(super) user: Option<String>,
337    /// Exposed ports
338    pub(super) exposed_ports: Vec<String>,
339    /// Labels
340    pub(super) labels: HashMap<String, String>,
341    /// Layer info accumulated during build
342    pub(super) layers: Vec<LayerInfo>,
343    /// Diff IDs (uncompressed layer digests) for the OCI config
344    pub(super) diff_ids: Vec<String>,
345    /// History entries
346    pub(super) history: Vec<HistoryEntry>,
347    /// Build arguments (all `--build-arg` values plus ARG defaults). A value is
348    /// only usable in variable expansion if its name is also in `declared_args`.
349    pub(super) build_args: HashMap<String, String>,
350    /// Names declared via an `ARG` instruction in scope for this stage (plus any
351    /// global pre-FROM ARGs). Docker only substitutes `$NAME` for declared names;
352    /// an undeclared `--build-arg` is ignored for expansion.
353    pub(super) declared_args: HashSet<String>,
354    /// Shell override (default: ["/bin/sh", "-c"])
355    pub(super) shell: Vec<String>,
356    /// Stop signal
357    pub(super) stop_signal: Option<String>,
358    /// Health check configuration
359    pub(super) health_check: Option<OciHealthCheck>,
360    /// ONBUILD triggers to store in the image config
361    pub(super) onbuild: Vec<String>,
362    /// Volumes declared via VOLUME instruction
363    pub(super) volumes: Vec<String>,
364}
365
366/// A single history entry for the OCI config.
367#[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    /// Build args whose names were declared via `ARG` (gates `$NAME` expansion,
399    /// so an undeclared `--build-arg` is not substituted — matching Docker).
400    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    /// Variables in scope for `$NAME`/`${NAME}` expansion in ENV/WORKDIR/FROM:
409    /// declared ARG values overlaid with already-set ENV (ENV wins), matching
410    /// Docker. An undeclared/unset name is left untouched by `expand_args`.
411    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    /// Environment for RUN: declared ARG values are available while executing
420    /// the command, and ENV values override ARGs with the same name. ARGs are
421    /// not persisted into the final image config unless an ENV stores them.
422    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    /// Seed a global (pre-FROM) ARG into this stage: declare its name and apply
433    /// its default unless a `--build-arg` already overrides it.
434    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
444/// Execute a full image build from a Dockerfile.
445///
446/// # Process
447///
448/// 1. Parse the Dockerfile
449/// 2. Pull the base image (FROM)
450/// 3. Extract base image layers into a temporary rootfs
451/// 4. Execute each instruction, creating layers as needed
452/// 5. Assemble the final OCI image layout
453/// 6. Store in the image store with the given tag
454///
455/// Supports multi-stage builds: each FROM starts a new stage. Only the final
456/// stage produces the output image. `COPY --from=<stage>` copies from a
457/// previous stage's rootfs.
458pub async fn build(config: BuildConfig, store: Arc<ImageStore>) -> Result<BuildResult> {
459    validate_build_config(&config)?;
460
461    // Parse Dockerfile
462    let dockerfile = Dockerfile::from_file(&config.dockerfile_path)?;
463
464    // Load the context's .dockerignore once; applied to every context COPY/ADD.
465    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    // Split instructions into stages by FROM
475    let stages = split_into_stages(&dockerfile.instructions);
476    // Global (pre-FROM) ARG declarations: in scope for every stage. Stage 0 also
477    // processes them inline (they are prepended to it), so they are only seeded
478    // into later stages to avoid double-counting.
479    let global_args = global_arg_decls(&dockerfile.instructions);
480    let total_stages = stages.len();
481
482    // Resolve --target to the stage that produces the output image (by alias or
483    // numeric index). Without --target the final stage is the output. Stages
484    // after the target are never executed.
485    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    // Track completed stages: (alias, rootfs_path)
497    let mut completed_stages: Vec<(Option<String>, PathBuf)> = Vec::new();
498    // Cache external images already pulled+extracted for `COPY --from=<image>`
499    // and RUN mount `from=<image>` sources so repeated references pull once.
500    let mut external_from_rootfs: HashMap<String, PathBuf> = HashMap::new();
501
502    // Create temp directory for build workspace
503    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        // Seed later stages with the global pre-FROM ARGs (stage 0 gets them
527        // inline). Without this, a later `FROM image:$GLOBAL_ARG` would not
528        // resolve and the global ARG would be unavailable to the stage body.
529        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        // Layer-level build cache (best-effort; None disables caching).
539        let cache = if config.no_cache {
540            None
541        } else {
542            BuildCache::open()
543        };
544        // Running chain key over all instructions in this stage. Reset at FROM.
545        let mut chain_key = String::new();
546        // Once a cache miss forces re-execution, all later layers must be rebuilt.
547        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            // Advance the chain key BEFORE the match so a cache-hit `continue`
572            // does not skip it. FROM resets the key (keyed on base content below);
573            // every other instruction extends it, including config-only ones
574            // (ENV/WORKDIR/...) since they affect later RUNs.
575            if !matches!(instruction, Instruction::From { .. }) {
576                // Use build-arg-expanded text in the cache key for instructions
577                // whose effect depends on ARG/--build-arg values, so a different
578                // build arg correctly invalidates downstream layers. (RUN/COPY
579                // paths are not arg-expanded by this engine, so their raw repr is
580                // faithful; build-arg-driven behavior reaches RUN only via ENV.)
581                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                        // COPY --from=<stage>: key on the ACTUAL source files'
612                        // content in the (already-built) source stage's rootfs.
613                        // Without this the output stage's chain key never depends
614                        // on what the source stage produced, so a changed builder
615                        // binary is served STALE from the on-disk build cache.
616                        // External-image sources resolve to Err here and fall to
617                        // None (the image ref is already in `repr`).
618                        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                    // Key the cache chain on the actual base image content so a
672                    // different base invalidates everything that follows. FROM
673                    // itself is never cached.
674                    chain_key = sha256_bytes(base_diff_ids.join(",").as_bytes());
675                    cache_valid = true;
676
677                    // Inherit config from base image
678                    apply_base_config(&mut state, &base_config);
679
680                    // Execute ONBUILD triggers from base image
681                    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                        // `--from` is a prior stage (by alias or index) or, like
754                        // Docker, an external image reference to pull and copy
755                        // from.
756                        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                        // .dockerignore applies to the build context, not to a
771                        // source stage's rootfs.
772                        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                    // Expand prior ENV/ARG in the WORKDIR path (Docker does too).
997                    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                        // Expand prior ENV (and declared ARGs) in the value, left
1016                        // to right, so `ENV A=/x B=$A/y` resolves B against A.
1017                        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                    // Store the raw instruction text for the image config
1178                    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                    // Create volume directories in rootfs
1200                    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        // Store completed stage rootfs for COPY --from
1217        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            // Stages after the --target stage are not part of the output; stop.
1224            break;
1225        }
1226    }
1227
1228    // Assemble the final OCI image from the output (final or --target) stage
1229    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    // Determine target platform (use first platform or host default)
1239    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
1273// =============================================================================
1274// Helper functions
1275// =============================================================================
1276
1277/// Attempt to reuse a cached layer for a layer-producing instruction.
1278///
1279/// On a cache hit (and only when `cache_valid` is still true and a cache is
1280/// open), this applies the cached layer's diff to `rootfs_dir` so later
1281/// instructions build on the correct rootfs, then records the layer, diff_id,
1282/// and a non-empty history entry in `state`. Returns `Some(())` on a hit (the
1283/// caller should `continue`), or `None` to fall through to normal execution.
1284struct 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    // Apply the cached diff so subsequent instructions see the right rootfs.
1320    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
1338/// Handle FROM: pull base image and extract layers into rootfs.
1339///
1340/// Returns (base_layers, base_diff_ids, base_config).
1341async 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    // Pull the base image
1354    let puller = ImagePuller::new(store.clone(), RegistryAuth::from_env());
1355    let oci_image = puller.pull(&image_ref).await?;
1356
1357    // Extract all layers into rootfs
1358    for layer_path in oci_image.layer_paths() {
1359        extract_layer(layer_path, rootfs_dir)?;
1360    }
1361
1362    // Collect base layer info
1363    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        // Compute diff_id (SHA256 of uncompressed content)
1371        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
1385/// Resolve an external image source when `from=<image>` is not a build stage:
1386/// pull the image and extract it to a temp rootfs (Docker behavior). Memoized
1387/// per build so several copies or RUN bind mounts from one image pull only once.
1388async 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
1464/// Assemble the final OCI image layout and store it.
1465async 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    // Create output directory
1475    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    // Collect all layers: base + new
1481    let mut all_layer_descriptors = Vec::new();
1482    let mut all_diff_ids: Vec<String> = base_diff_ids.to_vec();
1483
1484    // Copy base layers to output
1485    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    // Copy new layers to output
1498    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    // Merge diff_ids
1511    all_diff_ids.extend(state.diff_ids.iter().cloned());
1512
1513    // Build OCI config
1514    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    // Populate config section
1547    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            // OCI stores intervals in nanoseconds
1583            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    // Write config blob
1609    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    // Build manifest
1615    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    // Write index.json
1632    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    // Write oci-layout
1657    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    // Store in image store
1664    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}