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        // Layer-level build cache (best-effort; None disables caching).
537        let cache = if config.no_cache {
538            None
539        } else {
540            BuildCache::open()
541        };
542        // Running chain key over all instructions in this stage. Reset at FROM.
543        let mut chain_key = String::new();
544        // Once a cache miss forces re-execution, all later layers must be rebuilt.
545        let mut cache_valid = true;
546
547        for instruction in &stage.instructions {
548            global_step += 1;
549            let step = global_step;
550            let run_mount_source_roots = if let Instruction::Run {
551                bind_mounts,
552                cache_mounts,
553                ..
554            } = instruction
555            {
556                resolve_run_mount_source_roots(
557                    &completed_stages,
558                    bind_mounts,
559                    cache_mounts,
560                    &store,
561                    build_dir.path(),
562                    &mut external_from_rootfs,
563                )
564                .await?
565            } else {
566                None
567            };
568
569            // Advance the chain key BEFORE the match so a cache-hit `continue`
570            // does not skip it. FROM resets the key (keyed on base content below);
571            // every other instruction extends it, including config-only ones
572            // (ENV/WORKDIR/...) since they affect later RUNs.
573            if !matches!(instruction, Instruction::From { .. }) {
574                // Use build-arg-expanded text in the cache key for instructions
575                // whose effect depends on ARG/--build-arg values, so a different
576                // build arg correctly invalidates downstream layers. (RUN/COPY
577                // paths are not arg-expanded by this engine, so their raw repr is
578                // faithful; build-arg-driven behavior reaches RUN only via ENV.)
579                let repr = match instruction {
580                    Instruction::Env { vars } => {
581                        let pairs: Vec<String> = vars
582                            .iter()
583                            .map(|(k, v)| {
584                                format!("{}={}", k, expand_args(v, &state.expansion_vars()))
585                            })
586                            .collect();
587                        format!("ENV {}", pairs.join(" "))
588                    }
589                    Instruction::Arg { name, default } => {
590                        let effective = state
591                            .build_args
592                            .get(name)
593                            .cloned()
594                            .or_else(|| default.clone())
595                            .unwrap_or_default();
596                        format!("ARG {}={}", name, effective)
597                    }
598                    other => instruction_to_string(other),
599                };
600                let input_hash = match instruction {
601                    Instruction::Copy {
602                        src, from: None, ..
603                    } => hash_context_sources(&config.context_dir, src),
604                    Instruction::Copy {
605                        src,
606                        from: Some(from_ref),
607                        ..
608                    } => {
609                        // COPY --from=<stage>: key on the ACTUAL source files'
610                        // content in the (already-built) source stage's rootfs.
611                        // Without this the output stage's chain key never depends
612                        // on what the source stage produced, so a changed builder
613                        // binary is served STALE from the on-disk build cache.
614                        // External-image sources resolve to Err here and fall to
615                        // None (the image ref is already in `repr`).
616                        resolve_stage_rootfs(from_ref, &completed_stages)
617                            .ok()
618                            .and_then(|rootfs| hash_context_sources(rootfs, src))
619                    }
620                    Instruction::Add { src, .. } => hash_context_sources(&config.context_dir, src),
621                    Instruction::Run {
622                        cache_mounts,
623                        bind_mounts,
624                        ..
625                    } => run_mount_input_hash(
626                        &config.context_dir,
627                        run_mount_source_roots
628                            .as_deref()
629                            .unwrap_or(&completed_stages),
630                        cache_mounts,
631                        bind_mounts,
632                    ),
633                    _ => None,
634                };
635                chain_key = BuildCache::chain(&chain_key, &repr, input_hash.as_deref());
636            }
637
638            match instruction {
639                Instruction::From { image, alias } => {
640                    if !config.quiet {
641                        if total_stages > 1 {
642                            println!(
643                                "Step {}/{}: FROM {} (stage {}/{}{})",
644                                step,
645                                total_instructions,
646                                image,
647                                stage_idx + 1,
648                                total_stages,
649                                alias
650                                    .as_ref()
651                                    .map(|a| format!(" as {}", a))
652                                    .unwrap_or_default()
653                            );
654                        } else {
655                            println!("Step {}/{}: FROM {}", step, total_instructions, image);
656                        }
657                    }
658                    let (layers, diff_ids, base_config) = handle_from(
659                        image,
660                        &rootfs_dir,
661                        &layers_dir,
662                        &store,
663                        &state.declared_build_args(),
664                    )
665                    .await?;
666                    base_layers = layers;
667                    base_diff_ids = diff_ids;
668
669                    // Key the cache chain on the actual base image content so a
670                    // different base invalidates everything that follows. FROM
671                    // itself is never cached.
672                    chain_key = sha256_bytes(base_diff_ids.join(",").as_bytes());
673                    cache_valid = true;
674
675                    // Inherit config from base image
676                    apply_base_config(&mut state, &base_config);
677
678                    // Execute ONBUILD triggers from base image
679                    if !base_config.onbuild.is_empty() && !config.quiet {
680                        println!(
681                            "  Executing {} ONBUILD trigger(s) from base image",
682                            base_config.onbuild.len()
683                        );
684                    }
685                    for trigger in &base_config.onbuild {
686                        execute_onbuild_trigger(
687                            trigger,
688                            &mut state,
689                            &config,
690                            &rootfs_dir,
691                            &layers_dir,
692                            &base_layers,
693                            &completed_stages,
694                        )?;
695                    }
696
697                    state.history.push(HistoryEntry {
698                        created_by: format!("FROM {}", image),
699                        empty_layer: true,
700                    });
701                }
702
703                Instruction::Copy {
704                    src,
705                    dst,
706                    from,
707                    chown,
708                } => {
709                    let created_by = if let Some(from_ref) = from {
710                        format!("COPY --from={} {} {}", from_ref, src.join(" "), dst)
711                    } else if let Some(owner) = chown {
712                        format!("COPY --chown={} {} {}", owner, src.join(" "), dst)
713                    } else {
714                        format!("COPY {} {}", src.join(" "), dst)
715                    };
716                    if try_reuse_cached_layer(
717                        CachedLayerReuse {
718                            cache_valid,
719                            cache: cache.as_ref(),
720                            chain_key: &chain_key,
721                            rootfs_dir: &rootfs_dir,
722                            layers_dir: &layers_dir,
723                            layer_index: state.layers.len() + base_layers.len(),
724                            created_by: &created_by,
725                        },
726                        &mut state,
727                    )?
728                    .is_some()
729                    {
730                        if !config.quiet {
731                            println!(
732                                "Step {}/{}: {} (CACHED)",
733                                step, total_instructions, created_by
734                            );
735                        }
736                        continue;
737                    }
738                    cache_valid = false;
739
740                    if let Some(from_ref) = from {
741                        if !config.quiet {
742                            println!(
743                                "Step {}/{}: COPY --from={} {} {}",
744                                step,
745                                total_instructions,
746                                from_ref,
747                                src.join(" "),
748                                dst
749                            );
750                        }
751                        // `--from` is a prior stage (by alias or index) or, like
752                        // Docker, an external image reference to pull and copy
753                        // from.
754                        let from_rootfs: PathBuf =
755                            match resolve_stage_rootfs(from_ref, &completed_stages) {
756                                Ok(stage_rootfs) => stage_rootfs.to_path_buf(),
757                                Err(_) => {
758                                    resolve_external_from_rootfs(
759                                        from_ref,
760                                        "COPY --from",
761                                        &store,
762                                        build_dir.path(),
763                                        &mut external_from_rootfs,
764                                    )
765                                    .await?
766                                }
767                            };
768                        // .dockerignore applies to the build context, not to a
769                        // source stage's rootfs.
770                        let layer_info = handle_copy(
771                            src,
772                            dst,
773                            chown.as_deref(),
774                            &from_rootfs,
775                            &rootfs_dir,
776                            &layers_dir,
777                            &state.workdir,
778                            state.layers.len() + base_layers.len(),
779                            None,
780                        )?;
781                        let diff_id = compute_diff_id(&layer_info.path)?;
782                        if let Some(c) = &cache {
783                            c.store(&chain_key, &layer_info, &diff_id);
784                        }
785                        state.diff_ids.push(diff_id);
786                        state.layers.push(layer_info);
787                        state.history.push(HistoryEntry {
788                            created_by: format!(
789                                "COPY --from={} {} {}",
790                                from_ref,
791                                src.join(" "),
792                                dst
793                            ),
794                            empty_layer: false,
795                        });
796                    } else {
797                        if !config.quiet {
798                            println!(
799                                "Step {}/{}: COPY {} {}",
800                                step,
801                                total_instructions,
802                                src.join(" "),
803                                dst
804                            );
805                        }
806                        let layer_info = handle_copy(
807                            src,
808                            dst,
809                            chown.as_deref(),
810                            &config.context_dir,
811                            &rootfs_dir,
812                            &layers_dir,
813                            &state.workdir,
814                            state.layers.len() + base_layers.len(),
815                            Some(&dockerignore),
816                        )?;
817                        let diff_id = compute_diff_id(&layer_info.path)?;
818                        if let Some(c) = &cache {
819                            c.store(&chain_key, &layer_info, &diff_id);
820                        }
821                        state.diff_ids.push(diff_id);
822                        state.layers.push(layer_info);
823                        state.history.push(HistoryEntry {
824                            created_by: format!("COPY {} {}", src.join(" "), dst),
825                            empty_layer: false,
826                        });
827                    }
828                }
829
830                Instruction::Add { src, dst, chown } => {
831                    let created_by = format!("ADD {} {}", src.join(" "), dst);
832                    if try_reuse_cached_layer(
833                        CachedLayerReuse {
834                            cache_valid,
835                            cache: cache.as_ref(),
836                            chain_key: &chain_key,
837                            rootfs_dir: &rootfs_dir,
838                            layers_dir: &layers_dir,
839                            layer_index: state.layers.len() + base_layers.len(),
840                            created_by: &created_by,
841                        },
842                        &mut state,
843                    )?
844                    .is_some()
845                    {
846                        if !config.quiet {
847                            println!(
848                                "Step {}/{}: {} (CACHED)",
849                                step, total_instructions, created_by
850                            );
851                        }
852                        continue;
853                    }
854                    cache_valid = false;
855
856                    if !config.quiet {
857                        println!(
858                            "Step {}/{}: ADD {} {}",
859                            step,
860                            total_instructions,
861                            src.join(" "),
862                            dst
863                        );
864                    }
865                    let layer_info = handle_add(
866                        src,
867                        dst,
868                        chown.as_deref(),
869                        &config.context_dir,
870                        &rootfs_dir,
871                        &layers_dir,
872                        &state.workdir,
873                        state.layers.len() + base_layers.len(),
874                        Some(&dockerignore),
875                    )?;
876                    let diff_id = compute_diff_id(&layer_info.path)?;
877                    if let Some(c) = &cache {
878                        c.store(&chain_key, &layer_info, &diff_id);
879                    }
880                    state.diff_ids.push(diff_id);
881                    state.layers.push(layer_info);
882                    state.history.push(HistoryEntry {
883                        created_by: format!("ADD {} {}", src.join(" "), dst),
884                        empty_layer: false,
885                    });
886                }
887
888                Instruction::Run {
889                    command,
890                    cache_mounts,
891                    bind_mounts,
892                    tmpfs_mounts,
893                } => {
894                    let created_by = instruction_to_string(instruction);
895                    if try_reuse_cached_layer(
896                        CachedLayerReuse {
897                            cache_valid,
898                            cache: cache.as_ref(),
899                            chain_key: &chain_key,
900                            rootfs_dir: &rootfs_dir,
901                            layers_dir: &layers_dir,
902                            layer_index: state.layers.len() + base_layers.len(),
903                            created_by: &created_by,
904                        },
905                        &mut state,
906                    )?
907                    .is_some()
908                    {
909                        if !config.quiet {
910                            println!(
911                                "Step {}/{}: {} (CACHED)",
912                                step, total_instructions, created_by
913                            );
914                        }
915                        continue;
916                    }
917                    cache_valid = false;
918
919                    if !config.quiet {
920                        println!("Step {}/{}: {}", step, total_instructions, created_by);
921                    }
922                    let layer_opt = if let Some(pool_config) = &config.run_pool {
923                        // A lease is deliberately scoped to one RUN. The handler
924                        // destroys its VM before inspecting the shared rootfs, so
925                        // daemonized descendants cannot race layer capture.
926                        let session =
927                            BuildRunPoolSession::acquire(pool_config, &rootfs_dir).await?;
928                        handle_run_with_pool(
929                            command,
930                            cache_mounts,
931                            bind_mounts,
932                            tmpfs_mounts,
933                            &config.context_dir,
934                            run_mount_source_roots
935                                .as_deref()
936                                .unwrap_or(&completed_stages),
937                            &rootfs_dir,
938                            &layers_dir,
939                            &state.workdir,
940                            &state.run_env(),
941                            &state.shell,
942                            state.user.as_deref(),
943                            state.layers.len() + base_layers.len(),
944                            config.quiet,
945                            session,
946                            Some(&dockerignore),
947                        )
948                        .await?
949                    } else {
950                        handle_run(
951                            command,
952                            cache_mounts,
953                            bind_mounts,
954                            tmpfs_mounts,
955                            &config.context_dir,
956                            run_mount_source_roots
957                                .as_deref()
958                                .unwrap_or(&completed_stages),
959                            &rootfs_dir,
960                            &layers_dir,
961                            &state.workdir,
962                            &state.run_env(),
963                            &state.shell,
964                            state.layers.len() + base_layers.len(),
965                            config.quiet,
966                            Some(&dockerignore),
967                        )?
968                    };
969                    if let Some(layer_info) = layer_opt {
970                        let diff_id = compute_diff_id(&layer_info.path)?;
971                        if let Some(c) = &cache {
972                            c.store(&chain_key, &layer_info, &diff_id);
973                        }
974                        state.diff_ids.push(diff_id);
975                        state.layers.push(layer_info);
976                        state.history.push(HistoryEntry {
977                            created_by: created_by.clone(),
978                            empty_layer: false,
979                        });
980                    } else {
981                        state.history.push(HistoryEntry {
982                            created_by: created_by.clone(),
983                            empty_layer: true,
984                        });
985                    }
986                }
987
988                Instruction::Workdir { path } => {
989                    if !config.quiet {
990                        println!("Step {}/{}: WORKDIR {}", step, total_instructions, path);
991                    }
992                    // Expand prior ENV/ARG in the WORKDIR path (Docker does too).
993                    let expanded_path = expand_args(path, &state.expansion_vars());
994                    state.workdir = resolve_path(&state.workdir, &expanded_path);
995                    crate::oci::rootfs::ensure_guest_directory(
996                        &rootfs_dir,
997                        state.workdir.trim_start_matches('/'),
998                    )?;
999                    state.history.push(HistoryEntry {
1000                        created_by: format!("WORKDIR {}", path),
1001                        empty_layer: true,
1002                    });
1003                }
1004
1005                Instruction::Env { vars } => {
1006                    let display: Vec<String> =
1007                        vars.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
1008                    let display = display.join(" ");
1009                    if !config.quiet {
1010                        println!("Step {}/{}: ENV {}", step, total_instructions, display);
1011                    }
1012                    for (key, value) in vars {
1013                        // Expand prior ENV (and declared ARGs) in the value, left
1014                        // to right, so `ENV A=/x B=$A/y` resolves B against A.
1015                        let expanded_value = expand_args(value, &state.expansion_vars());
1016                        if let Some(existing) = state.env.iter_mut().find(|(k, _)| k == key) {
1017                            existing.1 = expanded_value;
1018                        } else {
1019                            state.env.push((key.clone(), expanded_value));
1020                        }
1021                    }
1022                    state.history.push(HistoryEntry {
1023                        created_by: format!("ENV {}", display),
1024                        empty_layer: true,
1025                    });
1026                }
1027
1028                Instruction::Entrypoint { exec } => {
1029                    if !config.quiet {
1030                        println!(
1031                            "Step {}/{}: ENTRYPOINT {:?}",
1032                            step, total_instructions, exec
1033                        );
1034                    }
1035                    state.entrypoint = Some(exec.clone());
1036                    state.history.push(HistoryEntry {
1037                        created_by: format!("ENTRYPOINT {:?}", exec),
1038                        empty_layer: true,
1039                    });
1040                }
1041
1042                Instruction::Cmd { exec } => {
1043                    if !config.quiet {
1044                        println!("Step {}/{}: CMD {:?}", step, total_instructions, exec);
1045                    }
1046                    state.cmd = Some(exec.clone());
1047                    state.history.push(HistoryEntry {
1048                        created_by: format!("CMD {:?}", exec),
1049                        empty_layer: true,
1050                    });
1051                }
1052
1053                Instruction::Expose { ports } => {
1054                    let joined = ports.join(" ");
1055                    if !config.quiet {
1056                        println!("Step {}/{}: EXPOSE {}", step, total_instructions, joined);
1057                    }
1058                    for port in ports {
1059                        if !state.exposed_ports.contains(port) {
1060                            state.exposed_ports.push(port.clone());
1061                        }
1062                    }
1063                    state.history.push(HistoryEntry {
1064                        created_by: format!("EXPOSE {}", joined),
1065                        empty_layer: true,
1066                    });
1067                }
1068
1069                Instruction::Label { pairs } => {
1070                    let joined = pairs
1071                        .iter()
1072                        .map(|(k, v)| format!("{}={}", k, v))
1073                        .collect::<Vec<_>>()
1074                        .join(" ");
1075                    if !config.quiet {
1076                        println!("Step {}/{}: LABEL {}", step, total_instructions, joined);
1077                    }
1078                    for (key, value) in pairs {
1079                        state.labels.insert(key.clone(), value.clone());
1080                    }
1081                    state.history.push(HistoryEntry {
1082                        created_by: format!("LABEL {}", joined),
1083                        empty_layer: true,
1084                    });
1085                }
1086
1087                Instruction::User { user } => {
1088                    if !config.quiet {
1089                        println!("Step {}/{}: USER {}", step, total_instructions, user);
1090                    }
1091                    state.user = Some(user.clone());
1092                    state.history.push(HistoryEntry {
1093                        created_by: format!("USER {}", user),
1094                        empty_layer: true,
1095                    });
1096                }
1097
1098                Instruction::Arg { name, default } => {
1099                    if !config.quiet {
1100                        println!("Step {}/{}: ARG {}", step, total_instructions, name);
1101                    }
1102                    state.declared_args.insert(name.clone());
1103                    if !state.build_args.contains_key(name) {
1104                        if let Some(val) = default {
1105                            state.build_args.insert(name.clone(), val.clone());
1106                        }
1107                    }
1108                    state.history.push(HistoryEntry {
1109                        created_by: format!("ARG {}", name),
1110                        empty_layer: true,
1111                    });
1112                }
1113
1114                Instruction::Shell { exec } => {
1115                    if !config.quiet {
1116                        println!("Step {}/{}: SHELL {:?}", step, total_instructions, exec);
1117                    }
1118                    state.shell = exec.clone();
1119                    state.history.push(HistoryEntry {
1120                        created_by: format!("SHELL {:?}", exec),
1121                        empty_layer: true,
1122                    });
1123                }
1124
1125                Instruction::StopSignal { signal } => {
1126                    if !config.quiet {
1127                        println!(
1128                            "Step {}/{}: STOPSIGNAL {}",
1129                            step, total_instructions, signal
1130                        );
1131                    }
1132                    state.stop_signal = Some(signal.clone());
1133                    state.history.push(HistoryEntry {
1134                        created_by: format!("STOPSIGNAL {}", signal),
1135                        empty_layer: true,
1136                    });
1137                }
1138
1139                Instruction::HealthCheck {
1140                    cmd,
1141                    interval,
1142                    timeout,
1143                    retries,
1144                    start_period,
1145                } => {
1146                    if !config.quiet {
1147                        if cmd.is_some() {
1148                            println!("Step {}/{}: HEALTHCHECK CMD ...", step, total_instructions);
1149                        } else {
1150                            println!("Step {}/{}: HEALTHCHECK NONE", step, total_instructions);
1151                        }
1152                    }
1153                    state.health_check = cmd.as_ref().map(|c| OciHealthCheck {
1154                        test: c.clone(),
1155                        interval: *interval,
1156                        timeout: *timeout,
1157                        retries: *retries,
1158                        start_period: *start_period,
1159                    });
1160                    state.history.push(HistoryEntry {
1161                        created_by: if cmd.is_some() {
1162                            "HEALTHCHECK CMD ...".to_string()
1163                        } else {
1164                            "HEALTHCHECK NONE".to_string()
1165                        },
1166                        empty_layer: true,
1167                    });
1168                }
1169
1170                Instruction::OnBuild { instruction } => {
1171                    let trigger = format!("{:?}", instruction);
1172                    if !config.quiet {
1173                        println!("Step {}/{}: ONBUILD {}", step, total_instructions, trigger);
1174                    }
1175                    // Store the raw instruction text for the image config
1176                    state.onbuild.push(instruction_to_string(instruction));
1177                    state.history.push(HistoryEntry {
1178                        created_by: format!("ONBUILD {}", instruction_to_string(instruction)),
1179                        empty_layer: true,
1180                    });
1181                }
1182
1183                Instruction::Volume { paths } => {
1184                    if !config.quiet {
1185                        println!(
1186                            "Step {}/{}: VOLUME {}",
1187                            step,
1188                            total_instructions,
1189                            paths.join(" ")
1190                        );
1191                    }
1192                    for p in paths {
1193                        if !state.volumes.contains(p) {
1194                            state.volumes.push(p.clone());
1195                        }
1196                    }
1197                    // Create volume directories in rootfs
1198                    for p in paths {
1199                        crate::oci::rootfs::ensure_guest_directory(
1200                            &rootfs_dir,
1201                            p.trim_start_matches('/'),
1202                        )?;
1203                    }
1204                    state.history.push(HistoryEntry {
1205                        created_by: format!("VOLUME {}", paths.join(" ")),
1206                        empty_layer: true,
1207                    });
1208                }
1209            }
1210        }
1211
1212        // Store completed stage rootfs for COPY --from
1213        completed_stages.push((stage.alias.clone(), rootfs_dir.clone()));
1214
1215        if is_final_stage {
1216            final_state = state;
1217            final_base_layers = base_layers;
1218            final_base_diff_ids = base_diff_ids;
1219            // Stages after the --target stage are not part of the output; stop.
1220            break;
1221        }
1222    }
1223
1224    // Assemble the final OCI image from the output (final or --target) stage
1225    let reference = config
1226        .tag
1227        .clone()
1228        .unwrap_or_else(|| "a3s-build:latest".to_string());
1229
1230    let final_layers_dir = build_dir
1231        .path()
1232        .join(format!("layers_{}", output_stage_idx));
1233
1234    // Determine target platform (use first platform or host default)
1235    let target_platform = config
1236        .platforms
1237        .first()
1238        .cloned()
1239        .unwrap_or_else(default_target_platform);
1240
1241    let result = assemble_image(
1242        &reference,
1243        &final_state,
1244        &final_base_layers,
1245        &final_base_diff_ids,
1246        &final_layers_dir,
1247        &store,
1248        &target_platform,
1249    )
1250    .await?;
1251
1252    if !config.quiet {
1253        println!(
1254            "Successfully built {} ({} layers, {}, {})",
1255            reference,
1256            result.layer_count,
1257            format_size(result.size),
1258            target_platform,
1259        );
1260    }
1261
1262    if let Some(ref m) = config.metrics {
1263        m.image_build_total.inc();
1264    }
1265
1266    Ok(result)
1267}
1268
1269// =============================================================================
1270// Helper functions
1271// =============================================================================
1272
1273/// Attempt to reuse a cached layer for a layer-producing instruction.
1274///
1275/// On a cache hit (and only when `cache_valid` is still true and a cache is
1276/// open), this applies the cached layer's diff to `rootfs_dir` so later
1277/// instructions build on the correct rootfs, then records the layer, diff_id,
1278/// and a non-empty history entry in `state`. Returns `Some(())` on a hit (the
1279/// caller should `continue`), or `None` to fall through to normal execution.
1280struct CachedLayerReuse<'a> {
1281    cache_valid: bool,
1282    cache: Option<&'a BuildCache>,
1283    chain_key: &'a str,
1284    rootfs_dir: &'a Path,
1285    layers_dir: &'a Path,
1286    layer_index: usize,
1287    created_by: &'a str,
1288}
1289
1290fn try_reuse_cached_layer(
1291    request: CachedLayerReuse<'_>,
1292    state: &mut BuildState,
1293) -> Result<Option<()>> {
1294    if !request.cache_valid {
1295        return Ok(None);
1296    }
1297    let Some(cached) = request.cache.and_then(|c| c.lookup(request.chain_key)) else {
1298        return Ok(None);
1299    };
1300
1301    let local_layer = request.layers_dir.join(format!(
1302        "cached_{}_{}.tar.gz",
1303        request.layer_index, cached.digest
1304    ));
1305    if let Err(error) = std::fs::copy(&cached.blob_path, &local_layer) {
1306        tracing::warn!(
1307            key = %request.chain_key,
1308            source = %cached.blob_path.display(),
1309            error = %error,
1310            "Build cache blob disappeared before it could be materialized; rebuilding instruction"
1311        );
1312        return Ok(None);
1313    }
1314
1315    // Apply the cached diff so subsequent instructions see the right rootfs.
1316    extract_layer(&local_layer, request.rootfs_dir)?;
1317    let local_size = std::fs::metadata(&local_layer)
1318        .map(|metadata| metadata.len())
1319        .unwrap_or(cached.size);
1320
1321    state.layers.push(LayerInfo {
1322        path: local_layer,
1323        digest: cached.digest,
1324        size: local_size,
1325    });
1326    state.diff_ids.push(cached.diff_id);
1327    state.history.push(HistoryEntry {
1328        created_by: request.created_by.to_string(),
1329        empty_layer: false,
1330    });
1331    Ok(Some(()))
1332}
1333
1334/// Handle FROM: pull base image and extract layers into rootfs.
1335///
1336/// Returns (base_layers, base_diff_ids, base_config).
1337async fn handle_from(
1338    image: &str,
1339    rootfs_dir: &Path,
1340    _layers_dir: &Path,
1341    store: &Arc<ImageStore>,
1342    build_args: &HashMap<String, String>,
1343) -> Result<(Vec<LayerInfo>, Vec<String>, OciImageConfig)> {
1344    let image_ref = expand_args(image, build_args);
1345    if image_ref == "scratch" {
1346        return Ok((Vec::new(), Vec::new(), scratch_config()));
1347    }
1348
1349    // Pull the base image
1350    let puller = ImagePuller::new(store.clone(), RegistryAuth::from_env());
1351    let oci_image = puller.pull(&image_ref).await?;
1352
1353    // Extract all layers into rootfs
1354    for layer_path in oci_image.layer_paths() {
1355        extract_layer(layer_path, rootfs_dir)?;
1356    }
1357
1358    // Collect base layer info
1359    let mut base_layers = Vec::new();
1360    let mut base_diff_ids = Vec::new();
1361
1362    for layer_path in oci_image.layer_paths() {
1363        let digest = sha256_file(layer_path)?;
1364        let size = std::fs::metadata(layer_path).map(|m| m.len()).unwrap_or(0);
1365
1366        // Compute diff_id (SHA256 of uncompressed content)
1367        let diff_id = compute_diff_id(layer_path)?;
1368        base_diff_ids.push(diff_id);
1369
1370        base_layers.push(LayerInfo {
1371            path: layer_path.to_path_buf(),
1372            digest,
1373            size,
1374        });
1375    }
1376
1377    let config = oci_image.config().clone();
1378    Ok((base_layers, base_diff_ids, config))
1379}
1380
1381/// Resolve an external image source when `from=<image>` is not a build stage:
1382/// pull the image and extract it to a temp rootfs (Docker behavior). Memoized
1383/// per build so several copies or RUN bind mounts from one image pull only once.
1384async fn resolve_external_from_rootfs(
1385    image_ref: &str,
1386    operation: &str,
1387    store: &Arc<ImageStore>,
1388    build_dir: &Path,
1389    cache: &mut HashMap<String, PathBuf>,
1390) -> Result<PathBuf> {
1391    if let Some(dir) = cache.get(image_ref) {
1392        return Ok(dir.clone());
1393    }
1394
1395    let dir = build_dir.join(format!("copyfrom_{}", cache.len()));
1396    std::fs::create_dir_all(&dir).map_err(|e| {
1397        BoxError::BuildError(format!(
1398            "Failed to create {operation} image rootfs {}: {}",
1399            dir.display(),
1400            e
1401        ))
1402    })?;
1403
1404    let puller = ImagePuller::new(store.clone(), RegistryAuth::from_env());
1405    let oci_image = puller.pull(image_ref).await.map_err(|e| {
1406        BoxError::BuildError(format!(
1407            "{operation} from={}: not a build stage and could not be pulled as an image: {}",
1408            image_ref, e
1409        ))
1410    })?;
1411    for layer_path in oci_image.layer_paths() {
1412        extract_layer(layer_path, &dir)?;
1413    }
1414
1415    cache.insert(image_ref.to_string(), dir.clone());
1416    Ok(dir)
1417}
1418
1419fn validate_build_config(config: &BuildConfig) -> Result<()> {
1420    if config.platforms.len() > 1 {
1421        return Err(BoxError::BuildError(
1422            "Multi-platform builds are not implemented yet; pass a single target platform"
1423                .to_string(),
1424        ));
1425    }
1426
1427    for platform in &config.platforms {
1428        if platform.os != "linux" {
1429            return Err(BoxError::BuildError(format!(
1430                "Only linux target platforms are supported for image builds, got {}",
1431                platform
1432            )));
1433        }
1434    }
1435
1436    Ok(())
1437}
1438
1439fn default_target_platform() -> Platform {
1440    let host = Platform::host();
1441    Platform::new("linux", host.architecture)
1442}
1443
1444fn scratch_config() -> OciImageConfig {
1445    OciImageConfig {
1446        entrypoint: None,
1447        cmd: None,
1448        env: Vec::new(),
1449        working_dir: None,
1450        user: None,
1451        exposed_ports: Vec::new(),
1452        labels: HashMap::new(),
1453        volumes: Vec::new(),
1454        stop_signal: None,
1455        health_check: None,
1456        onbuild: Vec::new(),
1457    }
1458}
1459
1460/// Assemble the final OCI image layout and store it.
1461async fn assemble_image(
1462    reference: &str,
1463    state: &BuildState,
1464    base_layers: &[LayerInfo],
1465    base_diff_ids: &[String],
1466    layers_dir: &Path,
1467    store: &Arc<ImageStore>,
1468    target_platform: &Platform,
1469) -> Result<BuildResult> {
1470    // Create output directory
1471    let output_dir = layers_dir.join("_output");
1472    let blobs_dir = output_dir.join("blobs").join("sha256");
1473    std::fs::create_dir_all(&blobs_dir)
1474        .map_err(|e| BoxError::BuildError(format!("Failed to create output blobs dir: {}", e)))?;
1475
1476    // Collect all layers: base + new
1477    let mut all_layer_descriptors = Vec::new();
1478    let mut all_diff_ids: Vec<String> = base_diff_ids.to_vec();
1479
1480    // Copy base layers to output
1481    for layer in base_layers {
1482        let blob_path = blobs_dir.join(&layer.digest);
1483        if !blob_path.exists() {
1484            copy_layer_blob(layer, &blob_path, "base layer")?;
1485        }
1486        all_layer_descriptors.push(serde_json::json!({
1487            "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
1488            "digest": layer.prefixed_digest(),
1489            "size": layer.size
1490        }));
1491    }
1492
1493    // Copy new layers to output
1494    for (i, layer) in state.layers.iter().enumerate() {
1495        let blob_path = blobs_dir.join(&layer.digest);
1496        if !blob_path.exists() {
1497            copy_layer_blob(layer, &blob_path, &format!("layer {i}"))?;
1498        }
1499        all_layer_descriptors.push(serde_json::json!({
1500            "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
1501            "digest": layer.prefixed_digest(),
1502            "size": layer.size
1503        }));
1504    }
1505
1506    // Merge diff_ids
1507    all_diff_ids.extend(state.diff_ids.iter().cloned());
1508
1509    // Build OCI config
1510    let now = chrono::Utc::now().to_rfc3339();
1511    let arch = target_platform.oci_arch();
1512
1513    let env_list: Vec<String> = state
1514        .env
1515        .iter()
1516        .map(|(k, v)| format!("{}={}", k, v))
1517        .collect();
1518
1519    let mut config_obj = serde_json::json!({
1520        "architecture": arch,
1521        "os": "linux",
1522        "created": now,
1523        "config": {},
1524        "rootfs": {
1525            "type": "layers",
1526            "diff_ids": all_diff_ids.iter()
1527                .map(|d| format!("sha256:{}", d))
1528                .collect::<Vec<_>>()
1529        },
1530        "history": state.history.iter().map(|h| {
1531            let mut entry = serde_json::json!({
1532                "created": now,
1533                "created_by": h.created_by
1534            });
1535            if h.empty_layer {
1536                entry["empty_layer"] = serde_json::json!(true);
1537            }
1538            entry
1539        }).collect::<Vec<_>>()
1540    });
1541
1542    // Populate config section
1543    let config_section = config_obj["config"].as_object_mut().unwrap();
1544    if !env_list.is_empty() {
1545        config_section.insert("Env".to_string(), serde_json::json!(env_list));
1546    }
1547    if let Some(ref ep) = state.entrypoint {
1548        config_section.insert("Entrypoint".to_string(), serde_json::json!(ep));
1549    }
1550    if let Some(ref cmd) = state.cmd {
1551        config_section.insert("Cmd".to_string(), serde_json::json!(cmd));
1552    }
1553    if state.workdir != "/" {
1554        config_section.insert("WorkingDir".to_string(), serde_json::json!(state.workdir));
1555    }
1556    if let Some(ref user) = state.user {
1557        config_section.insert("User".to_string(), serde_json::json!(user));
1558    }
1559    if !state.exposed_ports.is_empty() {
1560        let ports: HashMap<String, serde_json::Value> = state
1561            .exposed_ports
1562            .iter()
1563            .map(|p| (p.clone(), serde_json::json!({})))
1564            .collect();
1565        config_section.insert("ExposedPorts".to_string(), serde_json::json!(ports));
1566    }
1567    if !state.labels.is_empty() {
1568        config_section.insert("Labels".to_string(), serde_json::json!(state.labels));
1569    }
1570    if let Some(ref sig) = state.stop_signal {
1571        config_section.insert("StopSignal".to_string(), serde_json::json!(sig));
1572    }
1573    if let Some(ref hc) = state.health_check {
1574        let mut hc_obj = serde_json::json!({
1575            "Test": hc.test,
1576        });
1577        if let Some(interval) = hc.interval {
1578            // OCI stores intervals in nanoseconds
1579            hc_obj["Interval"] = serde_json::json!(interval * 1_000_000_000);
1580        }
1581        if let Some(timeout) = hc.timeout {
1582            hc_obj["Timeout"] = serde_json::json!(timeout * 1_000_000_000);
1583        }
1584        if let Some(retries) = hc.retries {
1585            hc_obj["Retries"] = serde_json::json!(retries);
1586        }
1587        if let Some(start_period) = hc.start_period {
1588            hc_obj["StartPeriod"] = serde_json::json!(start_period * 1_000_000_000);
1589        }
1590        config_section.insert("Healthcheck".to_string(), hc_obj);
1591    }
1592    if !state.onbuild.is_empty() {
1593        config_section.insert("OnBuild".to_string(), serde_json::json!(state.onbuild));
1594    }
1595    if !state.volumes.is_empty() {
1596        let vols: HashMap<String, serde_json::Value> = state
1597            .volumes
1598            .iter()
1599            .map(|v| (v.clone(), serde_json::json!({})))
1600            .collect();
1601        config_section.insert("Volumes".to_string(), serde_json::json!(vols));
1602    }
1603
1604    // Write config blob
1605    let config_bytes = serde_json::to_vec_pretty(&config_obj)?;
1606    let config_digest = sha256_bytes(&config_bytes);
1607    std::fs::write(blobs_dir.join(&config_digest), &config_bytes)
1608        .map_err(|e| BoxError::BuildError(format!("Failed to write config blob: {}", e)))?;
1609
1610    // Build manifest
1611    let manifest = serde_json::json!({
1612        "schemaVersion": 2,
1613        "mediaType": "application/vnd.oci.image.manifest.v1+json",
1614        "config": {
1615            "mediaType": "application/vnd.oci.image.config.v1+json",
1616            "digest": format!("sha256:{}", config_digest),
1617            "size": config_bytes.len()
1618        },
1619        "layers": all_layer_descriptors
1620    });
1621
1622    let manifest_bytes = serde_json::to_vec_pretty(&manifest)?;
1623    let manifest_digest = sha256_bytes(&manifest_bytes);
1624    std::fs::write(blobs_dir.join(&manifest_digest), &manifest_bytes)
1625        .map_err(|e| BoxError::BuildError(format!("Failed to write manifest blob: {}", e)))?;
1626
1627    // Write index.json
1628    let mut platform_obj = serde_json::json!({
1629        "os": target_platform.os,
1630        "architecture": target_platform.architecture
1631    });
1632    if let Some(ref variant) = target_platform.variant {
1633        platform_obj["variant"] = serde_json::json!(variant);
1634    }
1635
1636    let index = serde_json::json!({
1637        "schemaVersion": 2,
1638        "mediaType": "application/vnd.oci.image.index.v1+json",
1639        "manifests": [{
1640            "mediaType": "application/vnd.oci.image.manifest.v1+json",
1641            "digest": format!("sha256:{}", manifest_digest),
1642            "size": manifest_bytes.len(),
1643            "platform": platform_obj
1644        }]
1645    });
1646    std::fs::write(
1647        output_dir.join("index.json"),
1648        serde_json::to_string_pretty(&index)?,
1649    )
1650    .map_err(|e| BoxError::BuildError(format!("Failed to write index.json: {}", e)))?;
1651
1652    // Write oci-layout
1653    std::fs::write(
1654        output_dir.join("oci-layout"),
1655        r#"{"imageLayoutVersion":"1.0.0"}"#,
1656    )
1657    .map_err(|e| BoxError::BuildError(format!("Failed to write oci-layout: {}", e)))?;
1658
1659    // Store in image store
1660    let digest_str = format!("sha256:{}", manifest_digest);
1661    let stored = store.put(reference, &digest_str, &output_dir).await?;
1662
1663    let total_layers = base_layers.len() + state.layers.len();
1664
1665    Ok(BuildResult {
1666        reference: reference.to_string(),
1667        digest: digest_str,
1668        size: stored.size_bytes,
1669        layer_count: total_layers,
1670    })
1671}
1672
1673fn copy_layer_blob(layer: &LayerInfo, blob_path: &Path, label: &str) -> Result<()> {
1674    if !layer.path.exists() {
1675        return Err(BoxError::BuildError(format!(
1676            "Failed to copy {label}: source layer {} for digest {} does not exist",
1677            layer.path.display(),
1678            layer.prefixed_digest()
1679        )));
1680    }
1681
1682    std::fs::copy(&layer.path, blob_path).map_err(|e| {
1683        BoxError::BuildError(format!(
1684            "Failed to copy {label} from {} to {} (digest {}): {}",
1685            layer.path.display(),
1686            blob_path.display(),
1687            layer.prefixed_digest(),
1688            e
1689        ))
1690    })?;
1691    Ok(())
1692}