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};
16use super::dockerignore::DockerIgnore;
17use super::layer::{sha256_bytes, sha256_file, LayerInfo};
18use crate::oci::image::OciImageConfig;
19use crate::oci::layers::extract_layer;
20use crate::oci::store::ImageStore;
21use crate::oci::{ImagePuller, RegistryAuth};
22
23mod handlers;
24mod stages;
25mod utils;
26
27#[cfg(test)]
28mod tests;
29
30use handlers::{
31    apply_base_config, execute_onbuild_trigger, handle_add, handle_copy, handle_run,
32    instruction_to_string,
33};
34use stages::{global_arg_decls, resolve_stage_rootfs, split_into_stages};
35use utils::{compute_diff_id, expand_args, format_size, resolve_path};
36
37/// 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}
61
62/// Result of a successful build.
63#[derive(Debug)]
64pub struct BuildResult {
65    /// Image reference stored in the image store
66    pub reference: String,
67    /// Content digest
68    pub digest: String,
69    /// Total image size in bytes
70    pub size: u64,
71    /// Number of layers
72    pub layer_count: usize,
73}
74
75/// Mutable state accumulated during the build.
76pub(super) struct BuildState {
77    /// Working directory inside the image
78    pub(super) workdir: String,
79    /// Environment variables
80    pub(super) env: Vec<(String, String)>,
81    /// Entrypoint
82    pub(super) entrypoint: Option<Vec<String>>,
83    /// Default command
84    pub(super) cmd: Option<Vec<String>>,
85    /// User
86    pub(super) user: Option<String>,
87    /// Exposed ports
88    pub(super) exposed_ports: Vec<String>,
89    /// Labels
90    pub(super) labels: HashMap<String, String>,
91    /// Layer info accumulated during build
92    pub(super) layers: Vec<LayerInfo>,
93    /// Diff IDs (uncompressed layer digests) for the OCI config
94    pub(super) diff_ids: Vec<String>,
95    /// History entries
96    pub(super) history: Vec<HistoryEntry>,
97    /// Build arguments (all `--build-arg` values plus ARG defaults). A value is
98    /// only usable in variable expansion if its name is also in `declared_args`.
99    pub(super) build_args: HashMap<String, String>,
100    /// Names declared via an `ARG` instruction in scope for this stage (plus any
101    /// global pre-FROM ARGs). Docker only substitutes `$NAME` for declared names;
102    /// an undeclared `--build-arg` is ignored for expansion.
103    pub(super) declared_args: HashSet<String>,
104    /// Shell override (default: ["/bin/sh", "-c"])
105    pub(super) shell: Vec<String>,
106    /// Stop signal
107    pub(super) stop_signal: Option<String>,
108    /// Health check configuration
109    pub(super) health_check: Option<OciHealthCheck>,
110    /// ONBUILD triggers to store in the image config
111    pub(super) onbuild: Vec<String>,
112    /// Volumes declared via VOLUME instruction
113    pub(super) volumes: Vec<String>,
114}
115
116/// A single history entry for the OCI config.
117#[derive(Debug, Clone)]
118pub(super) struct HistoryEntry {
119    pub(super) created_by: String,
120    pub(super) empty_layer: bool,
121}
122
123pub use crate::oci::image::OciHealthCheck;
124
125impl BuildState {
126    fn new(build_args: HashMap<String, String>) -> Self {
127        Self {
128            workdir: "/".to_string(),
129            env: Vec::new(),
130            entrypoint: None,
131            cmd: None,
132            user: None,
133            exposed_ports: Vec::new(),
134            labels: HashMap::new(),
135            layers: Vec::new(),
136            diff_ids: Vec::new(),
137            history: Vec::new(),
138            build_args,
139            declared_args: HashSet::new(),
140            shell: vec!["/bin/sh".to_string(), "-c".to_string()],
141            stop_signal: None,
142            health_check: None,
143            onbuild: Vec::new(),
144            volumes: Vec::new(),
145        }
146    }
147
148    /// Build args whose names were declared via `ARG` (gates `$NAME` expansion,
149    /// so an undeclared `--build-arg` is not substituted — matching Docker).
150    fn declared_build_args(&self) -> HashMap<String, String> {
151        self.build_args
152            .iter()
153            .filter(|(name, _)| self.declared_args.contains(*name))
154            .map(|(name, value)| (name.clone(), value.clone()))
155            .collect()
156    }
157
158    /// Variables in scope for `$NAME`/`${NAME}` expansion in ENV/WORKDIR/FROM:
159    /// declared ARG values overlaid with already-set ENV (ENV wins), matching
160    /// Docker. An undeclared/unset name is left untouched by `expand_args`.
161    fn expansion_vars(&self) -> HashMap<String, String> {
162        let mut vars = self.declared_build_args();
163        for (key, value) in &self.env {
164            vars.insert(key.clone(), value.clone());
165        }
166        vars
167    }
168
169    /// Environment for RUN: declared ARG values are available while executing
170    /// the command, and ENV values override ARGs with the same name. ARGs are
171    /// not persisted into the final image config unless an ENV stores them.
172    fn run_env(&self) -> Vec<(String, String)> {
173        let mut vars = self.declared_build_args();
174        for (key, value) in &self.env {
175            vars.insert(key.clone(), value.clone());
176        }
177        let mut pairs = vars.into_iter().collect::<Vec<_>>();
178        pairs.sort_by(|a, b| a.0.cmp(&b.0));
179        pairs
180    }
181
182    /// Seed a global (pre-FROM) ARG into this stage: declare its name and apply
183    /// its default unless a `--build-arg` already overrides it.
184    fn seed_global_arg(&mut self, name: &str, default: Option<&str>) {
185        self.declared_args.insert(name.to_string());
186        if !self.build_args.contains_key(name) {
187            if let Some(val) = default {
188                self.build_args.insert(name.to_string(), val.to_string());
189            }
190        }
191    }
192}
193
194/// Execute a full image build from a Dockerfile.
195///
196/// # Process
197///
198/// 1. Parse the Dockerfile
199/// 2. Pull the base image (FROM)
200/// 3. Extract base image layers into a temporary rootfs
201/// 4. Execute each instruction, creating layers as needed
202/// 5. Assemble the final OCI image layout
203/// 6. Store in the image store with the given tag
204///
205/// Supports multi-stage builds: each FROM starts a new stage. Only the final
206/// stage produces the output image. `COPY --from=<stage>` copies from a
207/// previous stage's rootfs.
208pub async fn build(config: BuildConfig, store: Arc<ImageStore>) -> Result<BuildResult> {
209    validate_build_config(&config)?;
210
211    // Parse Dockerfile
212    let dockerfile = Dockerfile::from_file(&config.dockerfile_path)?;
213
214    // Load the context's .dockerignore once; applied to every context COPY/ADD.
215    let dockerignore = DockerIgnore::load(&config.context_dir);
216
217    if !config.quiet {
218        println!("Building from {}", config.dockerfile_path.display());
219        if !dockerignore.is_empty() {
220            println!("Using .dockerignore");
221        }
222    }
223
224    // Split instructions into stages by FROM
225    let stages = split_into_stages(&dockerfile.instructions);
226    // Global (pre-FROM) ARG declarations: in scope for every stage. Stage 0 also
227    // processes them inline (they are prepended to it), so they are only seeded
228    // into later stages to avoid double-counting.
229    let global_args = global_arg_decls(&dockerfile.instructions);
230    let total_stages = stages.len();
231
232    // Resolve --target to the stage that produces the output image (by alias or
233    // numeric index). Without --target the final stage is the output. Stages
234    // after the target are never executed.
235    let output_stage_idx = match config.target.as_deref() {
236        Some(target) => stages
237            .iter()
238            .position(|s| s.alias.as_deref() == Some(target))
239            .or_else(|| target.parse::<usize>().ok().filter(|i| *i < total_stages))
240            .ok_or_else(|| {
241                BoxError::BuildError(format!("target build stage '{}' not found", target))
242            })?,
243        None => total_stages - 1,
244    };
245
246    // Track completed stages: (alias, rootfs_path)
247    let mut completed_stages: Vec<(Option<String>, PathBuf)> = Vec::new();
248    // Cache external images already pulled+extracted for `COPY --from=<image>`
249    // (keyed by image ref) so multiple copies from one image pull once.
250    let mut external_from_rootfs: HashMap<String, PathBuf> = HashMap::new();
251
252    // Create temp directory for build workspace
253    let build_dir = tempfile::TempDir::new()
254        .map_err(|e| BoxError::BuildError(format!("Failed to create build directory: {}", e)))?;
255
256    let mut final_state = BuildState::new(config.build_args.clone());
257    let mut final_base_layers: Vec<LayerInfo> = Vec::new();
258    let mut final_base_diff_ids: Vec<String> = Vec::new();
259
260    let total_instructions = dockerfile.instructions.len();
261    let mut global_step = 0;
262
263    for (stage_idx, stage) in stages.iter().enumerate() {
264        let is_final_stage = stage_idx == output_stage_idx;
265
266        let rootfs_dir = build_dir.path().join(format!("rootfs_{}", stage_idx));
267        let layers_dir = build_dir.path().join(format!("layers_{}", stage_idx));
268        std::fs::create_dir_all(&rootfs_dir).map_err(|e| {
269            BoxError::BuildError(format!("Failed to create rootfs directory: {}", e))
270        })?;
271        std::fs::create_dir_all(&layers_dir).map_err(|e| {
272            BoxError::BuildError(format!("Failed to create layers directory: {}", e))
273        })?;
274
275        let mut state = BuildState::new(config.build_args.clone());
276        // Seed later stages with the global pre-FROM ARGs (stage 0 gets them
277        // inline). Without this, a later `FROM image:$GLOBAL_ARG` would not
278        // resolve and the global ARG would be unavailable to the stage body.
279        if stage_idx > 0 {
280            for (name, default) in &global_args {
281                state.seed_global_arg(name, default.as_deref());
282            }
283        }
284        let mut base_layers: Vec<LayerInfo> = Vec::new();
285        let mut base_diff_ids: Vec<String> = Vec::new();
286
287        // Layer-level build cache (best-effort; None disables caching).
288        let cache = if config.no_cache {
289            None
290        } else {
291            BuildCache::open()
292        };
293        // Running chain key over all instructions in this stage. Reset at FROM.
294        let mut chain_key = String::new();
295        // Once a cache miss forces re-execution, all later layers must be rebuilt.
296        let mut cache_valid = true;
297
298        for instruction in &stage.instructions {
299            global_step += 1;
300            let step = global_step;
301
302            // Advance the chain key BEFORE the match so a cache-hit `continue`
303            // does not skip it. FROM resets the key (keyed on base content below);
304            // every other instruction extends it, including config-only ones
305            // (ENV/WORKDIR/...) since they affect later RUNs.
306            if !matches!(instruction, Instruction::From { .. }) {
307                // Use build-arg-expanded text in the cache key for instructions
308                // whose effect depends on ARG/--build-arg values, so a different
309                // build arg correctly invalidates downstream layers. (RUN/COPY
310                // paths are not arg-expanded by this engine, so their raw repr is
311                // faithful; build-arg-driven behavior reaches RUN only via ENV.)
312                let repr = match instruction {
313                    Instruction::Env { vars } => {
314                        let pairs: Vec<String> = vars
315                            .iter()
316                            .map(|(k, v)| {
317                                format!("{}={}", k, expand_args(v, &state.expansion_vars()))
318                            })
319                            .collect();
320                        format!("ENV {}", pairs.join(" "))
321                    }
322                    Instruction::Arg { name, default } => {
323                        let effective = state
324                            .build_args
325                            .get(name)
326                            .cloned()
327                            .or_else(|| default.clone())
328                            .unwrap_or_default();
329                        format!("ARG {}={}", name, effective)
330                    }
331                    other => instruction_to_string(other),
332                };
333                let input_hash = match instruction {
334                    Instruction::Copy {
335                        src, from: None, ..
336                    } => hash_context_sources(&config.context_dir, src),
337                    Instruction::Copy {
338                        src,
339                        from: Some(from_ref),
340                        ..
341                    } => {
342                        // COPY --from=<stage>: key on the ACTUAL source files'
343                        // content in the (already-built) source stage's rootfs.
344                        // Without this the output stage's chain key never depends
345                        // on what the source stage produced, so a changed builder
346                        // binary is served STALE from the on-disk build cache.
347                        // External-image sources resolve to Err here and fall to
348                        // None (the image ref is already in `repr`).
349                        resolve_stage_rootfs(from_ref, &completed_stages)
350                            .ok()
351                            .and_then(|rootfs| hash_context_sources(rootfs, src))
352                    }
353                    Instruction::Add { src, .. } => hash_context_sources(&config.context_dir, src),
354                    _ => None,
355                };
356                chain_key = BuildCache::chain(&chain_key, &repr, input_hash.as_deref());
357            }
358
359            match instruction {
360                Instruction::From { image, alias } => {
361                    if !config.quiet {
362                        if total_stages > 1 {
363                            println!(
364                                "Step {}/{}: FROM {} (stage {}/{}{})",
365                                step,
366                                total_instructions,
367                                image,
368                                stage_idx + 1,
369                                total_stages,
370                                alias
371                                    .as_ref()
372                                    .map(|a| format!(" as {}", a))
373                                    .unwrap_or_default()
374                            );
375                        } else {
376                            println!("Step {}/{}: FROM {}", step, total_instructions, image);
377                        }
378                    }
379                    let (layers, diff_ids, base_config) = handle_from(
380                        image,
381                        &rootfs_dir,
382                        &layers_dir,
383                        &store,
384                        &state.declared_build_args(),
385                    )
386                    .await?;
387                    base_layers = layers;
388                    base_diff_ids = diff_ids;
389
390                    // Key the cache chain on the actual base image content so a
391                    // different base invalidates everything that follows. FROM
392                    // itself is never cached.
393                    chain_key = sha256_bytes(base_diff_ids.join(",").as_bytes());
394                    cache_valid = true;
395
396                    // Inherit config from base image
397                    apply_base_config(&mut state, &base_config);
398
399                    // Execute ONBUILD triggers from base image
400                    if !base_config.onbuild.is_empty() && !config.quiet {
401                        println!(
402                            "  Executing {} ONBUILD trigger(s) from base image",
403                            base_config.onbuild.len()
404                        );
405                    }
406                    for trigger in &base_config.onbuild {
407                        execute_onbuild_trigger(
408                            trigger,
409                            &mut state,
410                            &config,
411                            &rootfs_dir,
412                            &layers_dir,
413                            &base_layers,
414                            &completed_stages,
415                        )?;
416                    }
417
418                    state.history.push(HistoryEntry {
419                        created_by: format!("FROM {}", image),
420                        empty_layer: true,
421                    });
422                }
423
424                Instruction::Copy {
425                    src,
426                    dst,
427                    from,
428                    chown,
429                } => {
430                    let created_by = if let Some(from_ref) = from {
431                        format!("COPY --from={} {} {}", from_ref, src.join(" "), dst)
432                    } else if let Some(owner) = chown {
433                        format!("COPY --chown={} {} {}", owner, src.join(" "), dst)
434                    } else {
435                        format!("COPY {} {}", src.join(" "), dst)
436                    };
437                    if try_reuse_cached_layer(
438                        CachedLayerReuse {
439                            cache_valid,
440                            cache: cache.as_ref(),
441                            chain_key: &chain_key,
442                            rootfs_dir: &rootfs_dir,
443                            layers_dir: &layers_dir,
444                            layer_index: state.layers.len() + base_layers.len(),
445                            created_by: &created_by,
446                        },
447                        &mut state,
448                    )?
449                    .is_some()
450                    {
451                        if !config.quiet {
452                            println!(
453                                "Step {}/{}: {} (CACHED)",
454                                step, total_instructions, created_by
455                            );
456                        }
457                        continue;
458                    }
459                    cache_valid = false;
460
461                    if let Some(from_ref) = from {
462                        if !config.quiet {
463                            println!(
464                                "Step {}/{}: COPY --from={} {} {}",
465                                step,
466                                total_instructions,
467                                from_ref,
468                                src.join(" "),
469                                dst
470                            );
471                        }
472                        // `--from` is a prior stage (by alias or index) or, like
473                        // Docker, an external image reference to pull and copy
474                        // from.
475                        let from_rootfs: PathBuf =
476                            match resolve_stage_rootfs(from_ref, &completed_stages) {
477                                Ok(stage_rootfs) => stage_rootfs.to_path_buf(),
478                                Err(_) => {
479                                    resolve_external_image_rootfs(
480                                        from_ref,
481                                        &store,
482                                        build_dir.path(),
483                                        &mut external_from_rootfs,
484                                    )
485                                    .await?
486                                }
487                            };
488                        // .dockerignore applies to the build context, not to a
489                        // source stage's rootfs.
490                        let layer_info = handle_copy(
491                            src,
492                            dst,
493                            chown.as_deref(),
494                            &from_rootfs,
495                            &rootfs_dir,
496                            &layers_dir,
497                            &state.workdir,
498                            state.layers.len() + base_layers.len(),
499                            None,
500                        )?;
501                        let diff_id = compute_diff_id(&layer_info.path)?;
502                        if let Some(c) = &cache {
503                            c.store(&chain_key, &layer_info, &diff_id);
504                        }
505                        state.diff_ids.push(diff_id);
506                        state.layers.push(layer_info);
507                        state.history.push(HistoryEntry {
508                            created_by: format!(
509                                "COPY --from={} {} {}",
510                                from_ref,
511                                src.join(" "),
512                                dst
513                            ),
514                            empty_layer: false,
515                        });
516                    } else {
517                        if !config.quiet {
518                            println!(
519                                "Step {}/{}: COPY {} {}",
520                                step,
521                                total_instructions,
522                                src.join(" "),
523                                dst
524                            );
525                        }
526                        let layer_info = handle_copy(
527                            src,
528                            dst,
529                            chown.as_deref(),
530                            &config.context_dir,
531                            &rootfs_dir,
532                            &layers_dir,
533                            &state.workdir,
534                            state.layers.len() + base_layers.len(),
535                            Some(&dockerignore),
536                        )?;
537                        let diff_id = compute_diff_id(&layer_info.path)?;
538                        if let Some(c) = &cache {
539                            c.store(&chain_key, &layer_info, &diff_id);
540                        }
541                        state.diff_ids.push(diff_id);
542                        state.layers.push(layer_info);
543                        state.history.push(HistoryEntry {
544                            created_by: format!("COPY {} {}", src.join(" "), dst),
545                            empty_layer: false,
546                        });
547                    }
548                }
549
550                Instruction::Add { src, dst, chown } => {
551                    let created_by = format!("ADD {} {}", src.join(" "), dst);
552                    if try_reuse_cached_layer(
553                        CachedLayerReuse {
554                            cache_valid,
555                            cache: cache.as_ref(),
556                            chain_key: &chain_key,
557                            rootfs_dir: &rootfs_dir,
558                            layers_dir: &layers_dir,
559                            layer_index: state.layers.len() + base_layers.len(),
560                            created_by: &created_by,
561                        },
562                        &mut state,
563                    )?
564                    .is_some()
565                    {
566                        if !config.quiet {
567                            println!(
568                                "Step {}/{}: {} (CACHED)",
569                                step, total_instructions, created_by
570                            );
571                        }
572                        continue;
573                    }
574                    cache_valid = false;
575
576                    if !config.quiet {
577                        println!(
578                            "Step {}/{}: ADD {} {}",
579                            step,
580                            total_instructions,
581                            src.join(" "),
582                            dst
583                        );
584                    }
585                    let layer_info = handle_add(
586                        src,
587                        dst,
588                        chown.as_deref(),
589                        &config.context_dir,
590                        &rootfs_dir,
591                        &layers_dir,
592                        &state.workdir,
593                        state.layers.len() + base_layers.len(),
594                        Some(&dockerignore),
595                    )?;
596                    let diff_id = compute_diff_id(&layer_info.path)?;
597                    if let Some(c) = &cache {
598                        c.store(&chain_key, &layer_info, &diff_id);
599                    }
600                    state.diff_ids.push(diff_id);
601                    state.layers.push(layer_info);
602                    state.history.push(HistoryEntry {
603                        created_by: format!("ADD {} {}", src.join(" "), dst),
604                        empty_layer: false,
605                    });
606                }
607
608                Instruction::Run {
609                    command,
610                    cache_mounts,
611                } => {
612                    let created_by = instruction_to_string(instruction);
613                    if try_reuse_cached_layer(
614                        CachedLayerReuse {
615                            cache_valid,
616                            cache: cache.as_ref(),
617                            chain_key: &chain_key,
618                            rootfs_dir: &rootfs_dir,
619                            layers_dir: &layers_dir,
620                            layer_index: state.layers.len() + base_layers.len(),
621                            created_by: &created_by,
622                        },
623                        &mut state,
624                    )?
625                    .is_some()
626                    {
627                        if !config.quiet {
628                            println!(
629                                "Step {}/{}: {} (CACHED)",
630                                step, total_instructions, created_by
631                            );
632                        }
633                        continue;
634                    }
635                    cache_valid = false;
636
637                    if !config.quiet {
638                        println!("Step {}/{}: {}", step, total_instructions, created_by);
639                    }
640                    let layer_opt = handle_run(
641                        command,
642                        cache_mounts,
643                        &rootfs_dir,
644                        &layers_dir,
645                        &state.workdir,
646                        &state.run_env(),
647                        &state.shell,
648                        state.layers.len() + base_layers.len(),
649                        config.quiet,
650                    )?;
651                    if let Some(layer_info) = layer_opt {
652                        let diff_id = compute_diff_id(&layer_info.path)?;
653                        if let Some(c) = &cache {
654                            c.store(&chain_key, &layer_info, &diff_id);
655                        }
656                        state.diff_ids.push(diff_id);
657                        state.layers.push(layer_info);
658                        state.history.push(HistoryEntry {
659                            created_by: created_by.clone(),
660                            empty_layer: false,
661                        });
662                    } else {
663                        state.history.push(HistoryEntry {
664                            created_by: created_by.clone(),
665                            empty_layer: true,
666                        });
667                    }
668                }
669
670                Instruction::Workdir { path } => {
671                    if !config.quiet {
672                        println!("Step {}/{}: WORKDIR {}", step, total_instructions, path);
673                    }
674                    // Expand prior ENV/ARG in the WORKDIR path (Docker does too).
675                    let expanded_path = expand_args(path, &state.expansion_vars());
676                    state.workdir = resolve_path(&state.workdir, &expanded_path);
677                    let full = rootfs_dir.join(state.workdir.trim_start_matches('/'));
678                    let _ = std::fs::create_dir_all(&full);
679                    state.history.push(HistoryEntry {
680                        created_by: format!("WORKDIR {}", path),
681                        empty_layer: true,
682                    });
683                }
684
685                Instruction::Env { vars } => {
686                    let display: Vec<String> =
687                        vars.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
688                    let display = display.join(" ");
689                    if !config.quiet {
690                        println!("Step {}/{}: ENV {}", step, total_instructions, display);
691                    }
692                    for (key, value) in vars {
693                        // Expand prior ENV (and declared ARGs) in the value, left
694                        // to right, so `ENV A=/x B=$A/y` resolves B against A.
695                        let expanded_value = expand_args(value, &state.expansion_vars());
696                        if let Some(existing) = state.env.iter_mut().find(|(k, _)| k == key) {
697                            existing.1 = expanded_value;
698                        } else {
699                            state.env.push((key.clone(), expanded_value));
700                        }
701                    }
702                    state.history.push(HistoryEntry {
703                        created_by: format!("ENV {}", display),
704                        empty_layer: true,
705                    });
706                }
707
708                Instruction::Entrypoint { exec } => {
709                    if !config.quiet {
710                        println!(
711                            "Step {}/{}: ENTRYPOINT {:?}",
712                            step, total_instructions, exec
713                        );
714                    }
715                    state.entrypoint = Some(exec.clone());
716                    state.history.push(HistoryEntry {
717                        created_by: format!("ENTRYPOINT {:?}", exec),
718                        empty_layer: true,
719                    });
720                }
721
722                Instruction::Cmd { exec } => {
723                    if !config.quiet {
724                        println!("Step {}/{}: CMD {:?}", step, total_instructions, exec);
725                    }
726                    state.cmd = Some(exec.clone());
727                    state.history.push(HistoryEntry {
728                        created_by: format!("CMD {:?}", exec),
729                        empty_layer: true,
730                    });
731                }
732
733                Instruction::Expose { ports } => {
734                    let joined = ports.join(" ");
735                    if !config.quiet {
736                        println!("Step {}/{}: EXPOSE {}", step, total_instructions, joined);
737                    }
738                    for port in ports {
739                        if !state.exposed_ports.contains(port) {
740                            state.exposed_ports.push(port.clone());
741                        }
742                    }
743                    state.history.push(HistoryEntry {
744                        created_by: format!("EXPOSE {}", joined),
745                        empty_layer: true,
746                    });
747                }
748
749                Instruction::Label { pairs } => {
750                    let joined = pairs
751                        .iter()
752                        .map(|(k, v)| format!("{}={}", k, v))
753                        .collect::<Vec<_>>()
754                        .join(" ");
755                    if !config.quiet {
756                        println!("Step {}/{}: LABEL {}", step, total_instructions, joined);
757                    }
758                    for (key, value) in pairs {
759                        state.labels.insert(key.clone(), value.clone());
760                    }
761                    state.history.push(HistoryEntry {
762                        created_by: format!("LABEL {}", joined),
763                        empty_layer: true,
764                    });
765                }
766
767                Instruction::User { user } => {
768                    if !config.quiet {
769                        println!("Step {}/{}: USER {}", step, total_instructions, user);
770                    }
771                    state.user = Some(user.clone());
772                    state.history.push(HistoryEntry {
773                        created_by: format!("USER {}", user),
774                        empty_layer: true,
775                    });
776                }
777
778                Instruction::Arg { name, default } => {
779                    if !config.quiet {
780                        println!("Step {}/{}: ARG {}", step, total_instructions, name);
781                    }
782                    state.declared_args.insert(name.clone());
783                    if !state.build_args.contains_key(name) {
784                        if let Some(val) = default {
785                            state.build_args.insert(name.clone(), val.clone());
786                        }
787                    }
788                    state.history.push(HistoryEntry {
789                        created_by: format!("ARG {}", name),
790                        empty_layer: true,
791                    });
792                }
793
794                Instruction::Shell { exec } => {
795                    if !config.quiet {
796                        println!("Step {}/{}: SHELL {:?}", step, total_instructions, exec);
797                    }
798                    state.shell = exec.clone();
799                    state.history.push(HistoryEntry {
800                        created_by: format!("SHELL {:?}", exec),
801                        empty_layer: true,
802                    });
803                }
804
805                Instruction::StopSignal { signal } => {
806                    if !config.quiet {
807                        println!(
808                            "Step {}/{}: STOPSIGNAL {}",
809                            step, total_instructions, signal
810                        );
811                    }
812                    state.stop_signal = Some(signal.clone());
813                    state.history.push(HistoryEntry {
814                        created_by: format!("STOPSIGNAL {}", signal),
815                        empty_layer: true,
816                    });
817                }
818
819                Instruction::HealthCheck {
820                    cmd,
821                    interval,
822                    timeout,
823                    retries,
824                    start_period,
825                } => {
826                    if !config.quiet {
827                        if cmd.is_some() {
828                            println!("Step {}/{}: HEALTHCHECK CMD ...", step, total_instructions);
829                        } else {
830                            println!("Step {}/{}: HEALTHCHECK NONE", step, total_instructions);
831                        }
832                    }
833                    state.health_check = cmd.as_ref().map(|c| OciHealthCheck {
834                        test: c.clone(),
835                        interval: *interval,
836                        timeout: *timeout,
837                        retries: *retries,
838                        start_period: *start_period,
839                    });
840                    state.history.push(HistoryEntry {
841                        created_by: if cmd.is_some() {
842                            "HEALTHCHECK CMD ...".to_string()
843                        } else {
844                            "HEALTHCHECK NONE".to_string()
845                        },
846                        empty_layer: true,
847                    });
848                }
849
850                Instruction::OnBuild { instruction } => {
851                    let trigger = format!("{:?}", instruction);
852                    if !config.quiet {
853                        println!("Step {}/{}: ONBUILD {}", step, total_instructions, trigger);
854                    }
855                    // Store the raw instruction text for the image config
856                    state.onbuild.push(instruction_to_string(instruction));
857                    state.history.push(HistoryEntry {
858                        created_by: format!("ONBUILD {}", instruction_to_string(instruction)),
859                        empty_layer: true,
860                    });
861                }
862
863                Instruction::Volume { paths } => {
864                    if !config.quiet {
865                        println!(
866                            "Step {}/{}: VOLUME {}",
867                            step,
868                            total_instructions,
869                            paths.join(" ")
870                        );
871                    }
872                    for p in paths {
873                        if !state.volumes.contains(p) {
874                            state.volumes.push(p.clone());
875                        }
876                    }
877                    // Create volume directories in rootfs
878                    for p in paths {
879                        let full = rootfs_dir.join(p.trim_start_matches('/'));
880                        let _ = std::fs::create_dir_all(&full);
881                    }
882                    state.history.push(HistoryEntry {
883                        created_by: format!("VOLUME {}", paths.join(" ")),
884                        empty_layer: true,
885                    });
886                }
887            }
888        }
889
890        // Store completed stage rootfs for COPY --from
891        completed_stages.push((stage.alias.clone(), rootfs_dir.clone()));
892
893        if is_final_stage {
894            final_state = state;
895            final_base_layers = base_layers;
896            final_base_diff_ids = base_diff_ids;
897            // Stages after the --target stage are not part of the output; stop.
898            break;
899        }
900    }
901
902    // Assemble the final OCI image from the output (final or --target) stage
903    let reference = config
904        .tag
905        .clone()
906        .unwrap_or_else(|| "a3s-build:latest".to_string());
907
908    let final_layers_dir = build_dir
909        .path()
910        .join(format!("layers_{}", output_stage_idx));
911
912    // Determine target platform (use first platform or host default)
913    let target_platform = config
914        .platforms
915        .first()
916        .cloned()
917        .unwrap_or_else(default_target_platform);
918
919    let result = assemble_image(
920        &reference,
921        &final_state,
922        &final_base_layers,
923        &final_base_diff_ids,
924        &final_layers_dir,
925        &store,
926        &target_platform,
927    )
928    .await?;
929
930    if !config.quiet {
931        println!(
932            "Successfully built {} ({} layers, {}, {})",
933            reference,
934            result.layer_count,
935            format_size(result.size),
936            target_platform,
937        );
938    }
939
940    if let Some(ref m) = config.metrics {
941        m.image_build_total.inc();
942    }
943
944    Ok(result)
945}
946
947// =============================================================================
948// Helper functions
949// =============================================================================
950
951/// Attempt to reuse a cached layer for a layer-producing instruction.
952///
953/// On a cache hit (and only when `cache_valid` is still true and a cache is
954/// open), this applies the cached layer's diff to `rootfs_dir` so later
955/// instructions build on the correct rootfs, then records the layer, diff_id,
956/// and a non-empty history entry in `state`. Returns `Some(())` on a hit (the
957/// caller should `continue`), or `None` to fall through to normal execution.
958struct CachedLayerReuse<'a> {
959    cache_valid: bool,
960    cache: Option<&'a BuildCache>,
961    chain_key: &'a str,
962    rootfs_dir: &'a Path,
963    layers_dir: &'a Path,
964    layer_index: usize,
965    created_by: &'a str,
966}
967
968fn try_reuse_cached_layer(
969    request: CachedLayerReuse<'_>,
970    state: &mut BuildState,
971) -> Result<Option<()>> {
972    if !request.cache_valid {
973        return Ok(None);
974    }
975    let Some(cached) = request.cache.and_then(|c| c.lookup(request.chain_key)) else {
976        return Ok(None);
977    };
978
979    let local_layer = request.layers_dir.join(format!(
980        "cached_{}_{}.tar.gz",
981        request.layer_index, cached.digest
982    ));
983    if let Err(error) = std::fs::copy(&cached.blob_path, &local_layer) {
984        tracing::warn!(
985            key = %request.chain_key,
986            source = %cached.blob_path.display(),
987            error = %error,
988            "Build cache blob disappeared before it could be materialized; rebuilding instruction"
989        );
990        return Ok(None);
991    }
992
993    // Apply the cached diff so subsequent instructions see the right rootfs.
994    extract_layer(&local_layer, request.rootfs_dir)?;
995    let local_size = std::fs::metadata(&local_layer)
996        .map(|metadata| metadata.len())
997        .unwrap_or(cached.size);
998
999    state.layers.push(LayerInfo {
1000        path: local_layer,
1001        digest: cached.digest,
1002        size: local_size,
1003    });
1004    state.diff_ids.push(cached.diff_id);
1005    state.history.push(HistoryEntry {
1006        created_by: request.created_by.to_string(),
1007        empty_layer: false,
1008    });
1009    Ok(Some(()))
1010}
1011
1012/// Handle FROM: pull base image and extract layers into rootfs.
1013///
1014/// Returns (base_layers, base_diff_ids, base_config).
1015async fn handle_from(
1016    image: &str,
1017    rootfs_dir: &Path,
1018    _layers_dir: &Path,
1019    store: &Arc<ImageStore>,
1020    build_args: &HashMap<String, String>,
1021) -> Result<(Vec<LayerInfo>, Vec<String>, OciImageConfig)> {
1022    let image_ref = expand_args(image, build_args);
1023    if image_ref == "scratch" {
1024        return Ok((Vec::new(), Vec::new(), scratch_config()));
1025    }
1026
1027    // Pull the base image
1028    let puller = ImagePuller::new(store.clone(), RegistryAuth::from_env());
1029    let oci_image = puller.pull(&image_ref).await?;
1030
1031    // Extract all layers into rootfs
1032    for layer_path in oci_image.layer_paths() {
1033        extract_layer(layer_path, rootfs_dir)?;
1034    }
1035
1036    // Collect base layer info
1037    let mut base_layers = Vec::new();
1038    let mut base_diff_ids = Vec::new();
1039
1040    for layer_path in oci_image.layer_paths() {
1041        let digest = sha256_file(layer_path)?;
1042        let size = std::fs::metadata(layer_path).map(|m| m.len()).unwrap_or(0);
1043
1044        // Compute diff_id (SHA256 of uncompressed content)
1045        let diff_id = compute_diff_id(layer_path)?;
1046        base_diff_ids.push(diff_id);
1047
1048        base_layers.push(LayerInfo {
1049            path: layer_path.to_path_buf(),
1050            digest,
1051            size,
1052        });
1053    }
1054
1055    let config = oci_image.config().clone();
1056    Ok((base_layers, base_diff_ids, config))
1057}
1058
1059/// Resolve `COPY --from=<image>` when `<image>` is not a build stage: pull the
1060/// external image and extract it to a temp rootfs to copy from (Docker behavior).
1061/// Memoized per build so several copies from one image pull only once.
1062async fn resolve_external_image_rootfs(
1063    image_ref: &str,
1064    store: &Arc<ImageStore>,
1065    build_dir: &Path,
1066    cache: &mut HashMap<String, PathBuf>,
1067) -> Result<PathBuf> {
1068    if let Some(dir) = cache.get(image_ref) {
1069        return Ok(dir.clone());
1070    }
1071
1072    let dir = build_dir.join(format!("copyfrom_{}", cache.len()));
1073    std::fs::create_dir_all(&dir).map_err(|e| {
1074        BoxError::BuildError(format!(
1075            "Failed to create COPY --from image rootfs {}: {}",
1076            dir.display(),
1077            e
1078        ))
1079    })?;
1080
1081    let puller = ImagePuller::new(store.clone(), RegistryAuth::from_env());
1082    let oci_image = puller.pull(image_ref).await.map_err(|e| {
1083        BoxError::BuildError(format!(
1084            "COPY --from={}: not a build stage and could not be pulled as an image: {}",
1085            image_ref, e
1086        ))
1087    })?;
1088    for layer_path in oci_image.layer_paths() {
1089        extract_layer(layer_path, &dir)?;
1090    }
1091
1092    cache.insert(image_ref.to_string(), dir.clone());
1093    Ok(dir)
1094}
1095
1096fn validate_build_config(config: &BuildConfig) -> Result<()> {
1097    if config.platforms.len() > 1 {
1098        return Err(BoxError::BuildError(
1099            "Multi-platform builds are not implemented yet; pass a single target platform"
1100                .to_string(),
1101        ));
1102    }
1103
1104    for platform in &config.platforms {
1105        if platform.os != "linux" {
1106            return Err(BoxError::BuildError(format!(
1107                "Only linux target platforms are supported for image builds, got {}",
1108                platform
1109            )));
1110        }
1111    }
1112
1113    Ok(())
1114}
1115
1116fn default_target_platform() -> Platform {
1117    let host = Platform::host();
1118    Platform::new("linux", host.architecture)
1119}
1120
1121fn scratch_config() -> OciImageConfig {
1122    OciImageConfig {
1123        entrypoint: None,
1124        cmd: None,
1125        env: Vec::new(),
1126        working_dir: None,
1127        user: None,
1128        exposed_ports: Vec::new(),
1129        labels: HashMap::new(),
1130        volumes: Vec::new(),
1131        stop_signal: None,
1132        health_check: None,
1133        onbuild: Vec::new(),
1134    }
1135}
1136
1137/// Assemble the final OCI image layout and store it.
1138async fn assemble_image(
1139    reference: &str,
1140    state: &BuildState,
1141    base_layers: &[LayerInfo],
1142    base_diff_ids: &[String],
1143    layers_dir: &Path,
1144    store: &Arc<ImageStore>,
1145    target_platform: &Platform,
1146) -> Result<BuildResult> {
1147    // Create output directory
1148    let output_dir = layers_dir.join("_output");
1149    let blobs_dir = output_dir.join("blobs").join("sha256");
1150    std::fs::create_dir_all(&blobs_dir)
1151        .map_err(|e| BoxError::BuildError(format!("Failed to create output blobs dir: {}", e)))?;
1152
1153    // Collect all layers: base + new
1154    let mut all_layer_descriptors = Vec::new();
1155    let mut all_diff_ids: Vec<String> = base_diff_ids.to_vec();
1156
1157    // Copy base layers to output
1158    for layer in base_layers {
1159        let blob_path = blobs_dir.join(&layer.digest);
1160        if !blob_path.exists() {
1161            copy_layer_blob(layer, &blob_path, "base layer")?;
1162        }
1163        all_layer_descriptors.push(serde_json::json!({
1164            "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
1165            "digest": layer.prefixed_digest(),
1166            "size": layer.size
1167        }));
1168    }
1169
1170    // Copy new layers to output
1171    for (i, layer) in state.layers.iter().enumerate() {
1172        let blob_path = blobs_dir.join(&layer.digest);
1173        if !blob_path.exists() {
1174            copy_layer_blob(layer, &blob_path, &format!("layer {i}"))?;
1175        }
1176        all_layer_descriptors.push(serde_json::json!({
1177            "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
1178            "digest": layer.prefixed_digest(),
1179            "size": layer.size
1180        }));
1181    }
1182
1183    // Merge diff_ids
1184    all_diff_ids.extend(state.diff_ids.iter().cloned());
1185
1186    // Build OCI config
1187    let now = chrono::Utc::now().to_rfc3339();
1188    let arch = target_platform.oci_arch();
1189
1190    let env_list: Vec<String> = state
1191        .env
1192        .iter()
1193        .map(|(k, v)| format!("{}={}", k, v))
1194        .collect();
1195
1196    let mut config_obj = serde_json::json!({
1197        "architecture": arch,
1198        "os": "linux",
1199        "created": now,
1200        "config": {},
1201        "rootfs": {
1202            "type": "layers",
1203            "diff_ids": all_diff_ids.iter()
1204                .map(|d| format!("sha256:{}", d))
1205                .collect::<Vec<_>>()
1206        },
1207        "history": state.history.iter().map(|h| {
1208            let mut entry = serde_json::json!({
1209                "created": now,
1210                "created_by": h.created_by
1211            });
1212            if h.empty_layer {
1213                entry["empty_layer"] = serde_json::json!(true);
1214            }
1215            entry
1216        }).collect::<Vec<_>>()
1217    });
1218
1219    // Populate config section
1220    let config_section = config_obj["config"].as_object_mut().unwrap();
1221    if !env_list.is_empty() {
1222        config_section.insert("Env".to_string(), serde_json::json!(env_list));
1223    }
1224    if let Some(ref ep) = state.entrypoint {
1225        config_section.insert("Entrypoint".to_string(), serde_json::json!(ep));
1226    }
1227    if let Some(ref cmd) = state.cmd {
1228        config_section.insert("Cmd".to_string(), serde_json::json!(cmd));
1229    }
1230    if state.workdir != "/" {
1231        config_section.insert("WorkingDir".to_string(), serde_json::json!(state.workdir));
1232    }
1233    if let Some(ref user) = state.user {
1234        config_section.insert("User".to_string(), serde_json::json!(user));
1235    }
1236    if !state.exposed_ports.is_empty() {
1237        let ports: HashMap<String, serde_json::Value> = state
1238            .exposed_ports
1239            .iter()
1240            .map(|p| (p.clone(), serde_json::json!({})))
1241            .collect();
1242        config_section.insert("ExposedPorts".to_string(), serde_json::json!(ports));
1243    }
1244    if !state.labels.is_empty() {
1245        config_section.insert("Labels".to_string(), serde_json::json!(state.labels));
1246    }
1247    if let Some(ref sig) = state.stop_signal {
1248        config_section.insert("StopSignal".to_string(), serde_json::json!(sig));
1249    }
1250    if let Some(ref hc) = state.health_check {
1251        let mut hc_obj = serde_json::json!({
1252            "Test": hc.test,
1253        });
1254        if let Some(interval) = hc.interval {
1255            // OCI stores intervals in nanoseconds
1256            hc_obj["Interval"] = serde_json::json!(interval * 1_000_000_000);
1257        }
1258        if let Some(timeout) = hc.timeout {
1259            hc_obj["Timeout"] = serde_json::json!(timeout * 1_000_000_000);
1260        }
1261        if let Some(retries) = hc.retries {
1262            hc_obj["Retries"] = serde_json::json!(retries);
1263        }
1264        if let Some(start_period) = hc.start_period {
1265            hc_obj["StartPeriod"] = serde_json::json!(start_period * 1_000_000_000);
1266        }
1267        config_section.insert("Healthcheck".to_string(), hc_obj);
1268    }
1269    if !state.onbuild.is_empty() {
1270        config_section.insert("OnBuild".to_string(), serde_json::json!(state.onbuild));
1271    }
1272    if !state.volumes.is_empty() {
1273        let vols: HashMap<String, serde_json::Value> = state
1274            .volumes
1275            .iter()
1276            .map(|v| (v.clone(), serde_json::json!({})))
1277            .collect();
1278        config_section.insert("Volumes".to_string(), serde_json::json!(vols));
1279    }
1280
1281    // Write config blob
1282    let config_bytes = serde_json::to_vec_pretty(&config_obj)?;
1283    let config_digest = sha256_bytes(&config_bytes);
1284    std::fs::write(blobs_dir.join(&config_digest), &config_bytes)
1285        .map_err(|e| BoxError::BuildError(format!("Failed to write config blob: {}", e)))?;
1286
1287    // Build manifest
1288    let manifest = serde_json::json!({
1289        "schemaVersion": 2,
1290        "mediaType": "application/vnd.oci.image.manifest.v1+json",
1291        "config": {
1292            "mediaType": "application/vnd.oci.image.config.v1+json",
1293            "digest": format!("sha256:{}", config_digest),
1294            "size": config_bytes.len()
1295        },
1296        "layers": all_layer_descriptors
1297    });
1298
1299    let manifest_bytes = serde_json::to_vec_pretty(&manifest)?;
1300    let manifest_digest = sha256_bytes(&manifest_bytes);
1301    std::fs::write(blobs_dir.join(&manifest_digest), &manifest_bytes)
1302        .map_err(|e| BoxError::BuildError(format!("Failed to write manifest blob: {}", e)))?;
1303
1304    // Write index.json
1305    let mut platform_obj = serde_json::json!({
1306        "os": target_platform.os,
1307        "architecture": target_platform.architecture
1308    });
1309    if let Some(ref variant) = target_platform.variant {
1310        platform_obj["variant"] = serde_json::json!(variant);
1311    }
1312
1313    let index = serde_json::json!({
1314        "schemaVersion": 2,
1315        "mediaType": "application/vnd.oci.image.index.v1+json",
1316        "manifests": [{
1317            "mediaType": "application/vnd.oci.image.manifest.v1+json",
1318            "digest": format!("sha256:{}", manifest_digest),
1319            "size": manifest_bytes.len(),
1320            "platform": platform_obj
1321        }]
1322    });
1323    std::fs::write(
1324        output_dir.join("index.json"),
1325        serde_json::to_string_pretty(&index)?,
1326    )
1327    .map_err(|e| BoxError::BuildError(format!("Failed to write index.json: {}", e)))?;
1328
1329    // Write oci-layout
1330    std::fs::write(
1331        output_dir.join("oci-layout"),
1332        r#"{"imageLayoutVersion":"1.0.0"}"#,
1333    )
1334    .map_err(|e| BoxError::BuildError(format!("Failed to write oci-layout: {}", e)))?;
1335
1336    // Store in image store
1337    let digest_str = format!("sha256:{}", manifest_digest);
1338    let stored = store.put(reference, &digest_str, &output_dir).await?;
1339
1340    let total_layers = base_layers.len() + state.layers.len();
1341
1342    Ok(BuildResult {
1343        reference: reference.to_string(),
1344        digest: digest_str,
1345        size: stored.size_bytes,
1346        layer_count: total_layers,
1347    })
1348}
1349
1350fn copy_layer_blob(layer: &LayerInfo, blob_path: &Path, label: &str) -> Result<()> {
1351    if !layer.path.exists() {
1352        return Err(BoxError::BuildError(format!(
1353            "Failed to copy {label}: source layer {} for digest {} does not exist",
1354            layer.path.display(),
1355            layer.prefixed_digest()
1356        )));
1357    }
1358
1359    std::fs::copy(&layer.path, blob_path).map_err(|e| {
1360        BoxError::BuildError(format!(
1361            "Failed to copy {label} from {} to {} (digest {}): {}",
1362            layer.path.display(),
1363            blob_path.display(),
1364            layer.prefixed_digest(),
1365            e
1366        ))
1367    })?;
1368    Ok(())
1369}