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