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 {
588                    command,
589                    cache_mounts,
590                } => {
591                    let created_by = instruction_to_string(instruction);
592                    if try_reuse_cached_layer(
593                        cache_valid,
594                        cache.as_ref(),
595                        &chain_key,
596                        &rootfs_dir,
597                        &mut state,
598                        &created_by,
599                    )?
600                    .is_some()
601                    {
602                        if !config.quiet {
603                            println!(
604                                "Step {}/{}: {} (CACHED)",
605                                step, total_instructions, created_by
606                            );
607                        }
608                        continue;
609                    }
610                    cache_valid = false;
611
612                    if !config.quiet {
613                        println!("Step {}/{}: {}", step, total_instructions, created_by);
614                    }
615                    let layer_opt = handle_run(
616                        command,
617                        cache_mounts,
618                        &rootfs_dir,
619                        &layers_dir,
620                        &state.workdir,
621                        &state.env,
622                        &state.shell,
623                        state.layers.len() + base_layers.len(),
624                        config.quiet,
625                    )?;
626                    if let Some(layer_info) = layer_opt {
627                        let diff_id = compute_diff_id(&layer_info.path)?;
628                        if let Some(c) = &cache {
629                            c.store(&chain_key, &layer_info, &diff_id);
630                        }
631                        state.diff_ids.push(diff_id);
632                        state.layers.push(layer_info);
633                        state.history.push(HistoryEntry {
634                            created_by: created_by.clone(),
635                            empty_layer: false,
636                        });
637                    } else {
638                        state.history.push(HistoryEntry {
639                            created_by: created_by.clone(),
640                            empty_layer: true,
641                        });
642                    }
643                }
644
645                Instruction::Workdir { path } => {
646                    if !config.quiet {
647                        println!("Step {}/{}: WORKDIR {}", step, total_instructions, path);
648                    }
649                    // Expand prior ENV/ARG in the WORKDIR path (Docker does too).
650                    let expanded_path = expand_args(path, &state.expansion_vars());
651                    state.workdir = resolve_path(&state.workdir, &expanded_path);
652                    let full = rootfs_dir.join(state.workdir.trim_start_matches('/'));
653                    let _ = std::fs::create_dir_all(&full);
654                    state.history.push(HistoryEntry {
655                        created_by: format!("WORKDIR {}", path),
656                        empty_layer: true,
657                    });
658                }
659
660                Instruction::Env { vars } => {
661                    let display: Vec<String> =
662                        vars.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
663                    let display = display.join(" ");
664                    if !config.quiet {
665                        println!("Step {}/{}: ENV {}", step, total_instructions, display);
666                    }
667                    for (key, value) in vars {
668                        // Expand prior ENV (and declared ARGs) in the value, left
669                        // to right, so `ENV A=/x B=$A/y` resolves B against A.
670                        let expanded_value = expand_args(value, &state.expansion_vars());
671                        if let Some(existing) = state.env.iter_mut().find(|(k, _)| k == key) {
672                            existing.1 = expanded_value;
673                        } else {
674                            state.env.push((key.clone(), expanded_value));
675                        }
676                    }
677                    state.history.push(HistoryEntry {
678                        created_by: format!("ENV {}", display),
679                        empty_layer: true,
680                    });
681                }
682
683                Instruction::Entrypoint { exec } => {
684                    if !config.quiet {
685                        println!(
686                            "Step {}/{}: ENTRYPOINT {:?}",
687                            step, total_instructions, exec
688                        );
689                    }
690                    state.entrypoint = Some(exec.clone());
691                    state.history.push(HistoryEntry {
692                        created_by: format!("ENTRYPOINT {:?}", exec),
693                        empty_layer: true,
694                    });
695                }
696
697                Instruction::Cmd { exec } => {
698                    if !config.quiet {
699                        println!("Step {}/{}: CMD {:?}", step, total_instructions, exec);
700                    }
701                    state.cmd = Some(exec.clone());
702                    state.history.push(HistoryEntry {
703                        created_by: format!("CMD {:?}", exec),
704                        empty_layer: true,
705                    });
706                }
707
708                Instruction::Expose { ports } => {
709                    let joined = ports.join(" ");
710                    if !config.quiet {
711                        println!("Step {}/{}: EXPOSE {}", step, total_instructions, joined);
712                    }
713                    for port in ports {
714                        if !state.exposed_ports.contains(port) {
715                            state.exposed_ports.push(port.clone());
716                        }
717                    }
718                    state.history.push(HistoryEntry {
719                        created_by: format!("EXPOSE {}", joined),
720                        empty_layer: true,
721                    });
722                }
723
724                Instruction::Label { pairs } => {
725                    let joined = pairs
726                        .iter()
727                        .map(|(k, v)| format!("{}={}", k, v))
728                        .collect::<Vec<_>>()
729                        .join(" ");
730                    if !config.quiet {
731                        println!("Step {}/{}: LABEL {}", step, total_instructions, joined);
732                    }
733                    for (key, value) in pairs {
734                        state.labels.insert(key.clone(), value.clone());
735                    }
736                    state.history.push(HistoryEntry {
737                        created_by: format!("LABEL {}", joined),
738                        empty_layer: true,
739                    });
740                }
741
742                Instruction::User { user } => {
743                    if !config.quiet {
744                        println!("Step {}/{}: USER {}", step, total_instructions, user);
745                    }
746                    state.user = Some(user.clone());
747                    state.history.push(HistoryEntry {
748                        created_by: format!("USER {}", user),
749                        empty_layer: true,
750                    });
751                }
752
753                Instruction::Arg { name, default } => {
754                    if !config.quiet {
755                        println!("Step {}/{}: ARG {}", step, total_instructions, name);
756                    }
757                    state.declared_args.insert(name.clone());
758                    if !state.build_args.contains_key(name) {
759                        if let Some(val) = default {
760                            state.build_args.insert(name.clone(), val.clone());
761                        }
762                    }
763                    state.history.push(HistoryEntry {
764                        created_by: format!("ARG {}", name),
765                        empty_layer: true,
766                    });
767                }
768
769                Instruction::Shell { exec } => {
770                    if !config.quiet {
771                        println!("Step {}/{}: SHELL {:?}", step, total_instructions, exec);
772                    }
773                    state.shell = exec.clone();
774                    state.history.push(HistoryEntry {
775                        created_by: format!("SHELL {:?}", exec),
776                        empty_layer: true,
777                    });
778                }
779
780                Instruction::StopSignal { signal } => {
781                    if !config.quiet {
782                        println!(
783                            "Step {}/{}: STOPSIGNAL {}",
784                            step, total_instructions, signal
785                        );
786                    }
787                    state.stop_signal = Some(signal.clone());
788                    state.history.push(HistoryEntry {
789                        created_by: format!("STOPSIGNAL {}", signal),
790                        empty_layer: true,
791                    });
792                }
793
794                Instruction::HealthCheck {
795                    cmd,
796                    interval,
797                    timeout,
798                    retries,
799                    start_period,
800                } => {
801                    if !config.quiet {
802                        if cmd.is_some() {
803                            println!("Step {}/{}: HEALTHCHECK CMD ...", step, total_instructions);
804                        } else {
805                            println!("Step {}/{}: HEALTHCHECK NONE", step, total_instructions);
806                        }
807                    }
808                    state.health_check = cmd.as_ref().map(|c| OciHealthCheck {
809                        test: c.clone(),
810                        interval: *interval,
811                        timeout: *timeout,
812                        retries: *retries,
813                        start_period: *start_period,
814                    });
815                    state.history.push(HistoryEntry {
816                        created_by: if cmd.is_some() {
817                            "HEALTHCHECK CMD ...".to_string()
818                        } else {
819                            "HEALTHCHECK NONE".to_string()
820                        },
821                        empty_layer: true,
822                    });
823                }
824
825                Instruction::OnBuild { instruction } => {
826                    let trigger = format!("{:?}", instruction);
827                    if !config.quiet {
828                        println!("Step {}/{}: ONBUILD {}", step, total_instructions, trigger);
829                    }
830                    // Store the raw instruction text for the image config
831                    state.onbuild.push(instruction_to_string(instruction));
832                    state.history.push(HistoryEntry {
833                        created_by: format!("ONBUILD {}", instruction_to_string(instruction)),
834                        empty_layer: true,
835                    });
836                }
837
838                Instruction::Volume { paths } => {
839                    if !config.quiet {
840                        println!(
841                            "Step {}/{}: VOLUME {}",
842                            step,
843                            total_instructions,
844                            paths.join(" ")
845                        );
846                    }
847                    for p in paths {
848                        if !state.volumes.contains(p) {
849                            state.volumes.push(p.clone());
850                        }
851                    }
852                    // Create volume directories in rootfs
853                    for p in paths {
854                        let full = rootfs_dir.join(p.trim_start_matches('/'));
855                        let _ = std::fs::create_dir_all(&full);
856                    }
857                    state.history.push(HistoryEntry {
858                        created_by: format!("VOLUME {}", paths.join(" ")),
859                        empty_layer: true,
860                    });
861                }
862            }
863        }
864
865        // Store completed stage rootfs for COPY --from
866        completed_stages.push((stage.alias.clone(), rootfs_dir.clone()));
867
868        if is_final_stage {
869            final_state = state;
870            final_base_layers = base_layers;
871            final_base_diff_ids = base_diff_ids;
872            // Stages after the --target stage are not part of the output; stop.
873            break;
874        }
875    }
876
877    // Assemble the final OCI image from the output (final or --target) stage
878    let reference = config
879        .tag
880        .clone()
881        .unwrap_or_else(|| "a3s-build:latest".to_string());
882
883    let final_layers_dir = build_dir
884        .path()
885        .join(format!("layers_{}", output_stage_idx));
886
887    // Determine target platform (use first platform or host default)
888    let target_platform = config
889        .platforms
890        .first()
891        .cloned()
892        .unwrap_or_else(default_target_platform);
893
894    let result = assemble_image(
895        &reference,
896        &final_state,
897        &final_base_layers,
898        &final_base_diff_ids,
899        &final_layers_dir,
900        &store,
901        &target_platform,
902    )
903    .await?;
904
905    if !config.quiet {
906        println!(
907            "Successfully built {} ({} layers, {}, {})",
908            reference,
909            result.layer_count,
910            format_size(result.size),
911            target_platform,
912        );
913    }
914
915    if let Some(ref m) = config.metrics {
916        m.image_build_total.inc();
917    }
918
919    Ok(result)
920}
921
922// =============================================================================
923// Helper functions
924// =============================================================================
925
926/// Attempt to reuse a cached layer for a layer-producing instruction.
927///
928/// On a cache hit (and only when `cache_valid` is still true and a cache is
929/// open), this applies the cached layer's diff to `rootfs_dir` so later
930/// instructions build on the correct rootfs, then records the layer, diff_id,
931/// and a non-empty history entry in `state`. Returns `Some(())` on a hit (the
932/// caller should `continue`), or `None` to fall through to normal execution.
933fn try_reuse_cached_layer(
934    cache_valid: bool,
935    cache: Option<&BuildCache>,
936    chain_key: &str,
937    rootfs_dir: &Path,
938    state: &mut BuildState,
939    created_by: &str,
940) -> Result<Option<()>> {
941    if !cache_valid {
942        return Ok(None);
943    }
944    let Some(cached) = cache.and_then(|c| c.lookup(chain_key)) else {
945        return Ok(None);
946    };
947
948    // Apply the cached diff so subsequent instructions see the right rootfs.
949    extract_layer(&cached.blob_path, rootfs_dir)?;
950
951    state.layers.push(LayerInfo {
952        path: cached.blob_path,
953        digest: cached.digest,
954        size: cached.size,
955    });
956    state.diff_ids.push(cached.diff_id);
957    state.history.push(HistoryEntry {
958        created_by: created_by.to_string(),
959        empty_layer: false,
960    });
961    Ok(Some(()))
962}
963
964/// Handle FROM: pull base image and extract layers into rootfs.
965///
966/// Returns (base_layers, base_diff_ids, base_config).
967async fn handle_from(
968    image: &str,
969    rootfs_dir: &Path,
970    _layers_dir: &Path,
971    store: &Arc<ImageStore>,
972    build_args: &HashMap<String, String>,
973) -> Result<(Vec<LayerInfo>, Vec<String>, OciImageConfig)> {
974    let image_ref = expand_args(image, build_args);
975    if image_ref == "scratch" {
976        return Ok((Vec::new(), Vec::new(), scratch_config()));
977    }
978
979    // Pull the base image
980    let puller = ImagePuller::new(store.clone(), RegistryAuth::from_env());
981    let oci_image = puller.pull(&image_ref).await?;
982
983    // Extract all layers into rootfs
984    for layer_path in oci_image.layer_paths() {
985        extract_layer(layer_path, rootfs_dir)?;
986    }
987
988    // Collect base layer info
989    let mut base_layers = Vec::new();
990    let mut base_diff_ids = Vec::new();
991
992    for layer_path in oci_image.layer_paths() {
993        let digest = sha256_file(layer_path)?;
994        let size = std::fs::metadata(layer_path).map(|m| m.len()).unwrap_or(0);
995
996        // Compute diff_id (SHA256 of uncompressed content)
997        let diff_id = compute_diff_id(layer_path)?;
998        base_diff_ids.push(diff_id);
999
1000        base_layers.push(LayerInfo {
1001            path: layer_path.to_path_buf(),
1002            digest,
1003            size,
1004        });
1005    }
1006
1007    let config = oci_image.config().clone();
1008    Ok((base_layers, base_diff_ids, config))
1009}
1010
1011/// Resolve `COPY --from=<image>` when `<image>` is not a build stage: pull the
1012/// external image and extract it to a temp rootfs to copy from (Docker behavior).
1013/// Memoized per build so several copies from one image pull only once.
1014async fn resolve_external_image_rootfs(
1015    image_ref: &str,
1016    store: &Arc<ImageStore>,
1017    build_dir: &Path,
1018    cache: &mut HashMap<String, PathBuf>,
1019) -> Result<PathBuf> {
1020    if let Some(dir) = cache.get(image_ref) {
1021        return Ok(dir.clone());
1022    }
1023
1024    let dir = build_dir.join(format!("copyfrom_{}", cache.len()));
1025    std::fs::create_dir_all(&dir).map_err(|e| {
1026        BoxError::BuildError(format!(
1027            "Failed to create COPY --from image rootfs {}: {}",
1028            dir.display(),
1029            e
1030        ))
1031    })?;
1032
1033    let puller = ImagePuller::new(store.clone(), RegistryAuth::from_env());
1034    let oci_image = puller.pull(image_ref).await.map_err(|e| {
1035        BoxError::BuildError(format!(
1036            "COPY --from={}: not a build stage and could not be pulled as an image: {}",
1037            image_ref, e
1038        ))
1039    })?;
1040    for layer_path in oci_image.layer_paths() {
1041        extract_layer(layer_path, &dir)?;
1042    }
1043
1044    cache.insert(image_ref.to_string(), dir.clone());
1045    Ok(dir)
1046}
1047
1048fn validate_build_config(config: &BuildConfig) -> Result<()> {
1049    if config.platforms.len() > 1 {
1050        return Err(BoxError::BuildError(
1051            "Multi-platform builds are not implemented yet; pass a single target platform"
1052                .to_string(),
1053        ));
1054    }
1055
1056    for platform in &config.platforms {
1057        if platform.os != "linux" {
1058            return Err(BoxError::BuildError(format!(
1059                "Only linux target platforms are supported for image builds, got {}",
1060                platform
1061            )));
1062        }
1063    }
1064
1065    Ok(())
1066}
1067
1068fn default_target_platform() -> Platform {
1069    let host = Platform::host();
1070    Platform::new("linux", host.architecture)
1071}
1072
1073fn scratch_config() -> OciImageConfig {
1074    OciImageConfig {
1075        entrypoint: None,
1076        cmd: None,
1077        env: Vec::new(),
1078        working_dir: None,
1079        user: None,
1080        exposed_ports: Vec::new(),
1081        labels: HashMap::new(),
1082        volumes: Vec::new(),
1083        stop_signal: None,
1084        health_check: None,
1085        onbuild: Vec::new(),
1086    }
1087}
1088
1089/// Assemble the final OCI image layout and store it.
1090async fn assemble_image(
1091    reference: &str,
1092    state: &BuildState,
1093    base_layers: &[LayerInfo],
1094    base_diff_ids: &[String],
1095    layers_dir: &Path,
1096    store: &Arc<ImageStore>,
1097    target_platform: &Platform,
1098) -> Result<BuildResult> {
1099    // Create output directory
1100    let output_dir = layers_dir.join("_output");
1101    let blobs_dir = output_dir.join("blobs").join("sha256");
1102    std::fs::create_dir_all(&blobs_dir)
1103        .map_err(|e| BoxError::BuildError(format!("Failed to create output blobs dir: {}", e)))?;
1104
1105    // Collect all layers: base + new
1106    let mut all_layer_descriptors = Vec::new();
1107    let mut all_diff_ids: Vec<String> = base_diff_ids.to_vec();
1108
1109    // Copy base layers to output
1110    for layer in base_layers {
1111        let blob_path = blobs_dir.join(&layer.digest);
1112        if !blob_path.exists() {
1113            std::fs::copy(&layer.path, &blob_path)
1114                .map_err(|e| BoxError::BuildError(format!("Failed to copy base layer: {}", e)))?;
1115        }
1116        all_layer_descriptors.push(serde_json::json!({
1117            "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
1118            "digest": layer.prefixed_digest(),
1119            "size": layer.size
1120        }));
1121    }
1122
1123    // Copy new layers to output
1124    for (i, layer) in state.layers.iter().enumerate() {
1125        let blob_path = blobs_dir.join(&layer.digest);
1126        if !blob_path.exists() {
1127            std::fs::copy(&layer.path, &blob_path)
1128                .map_err(|e| BoxError::BuildError(format!("Failed to copy layer {}: {}", i, e)))?;
1129        }
1130        all_layer_descriptors.push(serde_json::json!({
1131            "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
1132            "digest": layer.prefixed_digest(),
1133            "size": layer.size
1134        }));
1135    }
1136
1137    // Merge diff_ids
1138    all_diff_ids.extend(state.diff_ids.iter().cloned());
1139
1140    // Build OCI config
1141    let now = chrono::Utc::now().to_rfc3339();
1142    let arch = target_platform.oci_arch();
1143
1144    let env_list: Vec<String> = state
1145        .env
1146        .iter()
1147        .map(|(k, v)| format!("{}={}", k, v))
1148        .collect();
1149
1150    let mut config_obj = serde_json::json!({
1151        "architecture": arch,
1152        "os": "linux",
1153        "created": now,
1154        "config": {},
1155        "rootfs": {
1156            "type": "layers",
1157            "diff_ids": all_diff_ids.iter()
1158                .map(|d| format!("sha256:{}", d))
1159                .collect::<Vec<_>>()
1160        },
1161        "history": state.history.iter().map(|h| {
1162            let mut entry = serde_json::json!({
1163                "created": now,
1164                "created_by": h.created_by
1165            });
1166            if h.empty_layer {
1167                entry["empty_layer"] = serde_json::json!(true);
1168            }
1169            entry
1170        }).collect::<Vec<_>>()
1171    });
1172
1173    // Populate config section
1174    let config_section = config_obj["config"].as_object_mut().unwrap();
1175    if !env_list.is_empty() {
1176        config_section.insert("Env".to_string(), serde_json::json!(env_list));
1177    }
1178    if let Some(ref ep) = state.entrypoint {
1179        config_section.insert("Entrypoint".to_string(), serde_json::json!(ep));
1180    }
1181    if let Some(ref cmd) = state.cmd {
1182        config_section.insert("Cmd".to_string(), serde_json::json!(cmd));
1183    }
1184    if state.workdir != "/" {
1185        config_section.insert("WorkingDir".to_string(), serde_json::json!(state.workdir));
1186    }
1187    if let Some(ref user) = state.user {
1188        config_section.insert("User".to_string(), serde_json::json!(user));
1189    }
1190    if !state.exposed_ports.is_empty() {
1191        let ports: HashMap<String, serde_json::Value> = state
1192            .exposed_ports
1193            .iter()
1194            .map(|p| (p.clone(), serde_json::json!({})))
1195            .collect();
1196        config_section.insert("ExposedPorts".to_string(), serde_json::json!(ports));
1197    }
1198    if !state.labels.is_empty() {
1199        config_section.insert("Labels".to_string(), serde_json::json!(state.labels));
1200    }
1201    if let Some(ref sig) = state.stop_signal {
1202        config_section.insert("StopSignal".to_string(), serde_json::json!(sig));
1203    }
1204    if let Some(ref hc) = state.health_check {
1205        let mut hc_obj = serde_json::json!({
1206            "Test": hc.test,
1207        });
1208        if let Some(interval) = hc.interval {
1209            // OCI stores intervals in nanoseconds
1210            hc_obj["Interval"] = serde_json::json!(interval * 1_000_000_000);
1211        }
1212        if let Some(timeout) = hc.timeout {
1213            hc_obj["Timeout"] = serde_json::json!(timeout * 1_000_000_000);
1214        }
1215        if let Some(retries) = hc.retries {
1216            hc_obj["Retries"] = serde_json::json!(retries);
1217        }
1218        if let Some(start_period) = hc.start_period {
1219            hc_obj["StartPeriod"] = serde_json::json!(start_period * 1_000_000_000);
1220        }
1221        config_section.insert("Healthcheck".to_string(), hc_obj);
1222    }
1223    if !state.onbuild.is_empty() {
1224        config_section.insert("OnBuild".to_string(), serde_json::json!(state.onbuild));
1225    }
1226    if !state.volumes.is_empty() {
1227        let vols: HashMap<String, serde_json::Value> = state
1228            .volumes
1229            .iter()
1230            .map(|v| (v.clone(), serde_json::json!({})))
1231            .collect();
1232        config_section.insert("Volumes".to_string(), serde_json::json!(vols));
1233    }
1234
1235    // Write config blob
1236    let config_bytes = serde_json::to_vec_pretty(&config_obj)?;
1237    let config_digest = sha256_bytes(&config_bytes);
1238    std::fs::write(blobs_dir.join(&config_digest), &config_bytes)
1239        .map_err(|e| BoxError::BuildError(format!("Failed to write config blob: {}", e)))?;
1240
1241    // Build manifest
1242    let manifest = serde_json::json!({
1243        "schemaVersion": 2,
1244        "mediaType": "application/vnd.oci.image.manifest.v1+json",
1245        "config": {
1246            "mediaType": "application/vnd.oci.image.config.v1+json",
1247            "digest": format!("sha256:{}", config_digest),
1248            "size": config_bytes.len()
1249        },
1250        "layers": all_layer_descriptors
1251    });
1252
1253    let manifest_bytes = serde_json::to_vec_pretty(&manifest)?;
1254    let manifest_digest = sha256_bytes(&manifest_bytes);
1255    std::fs::write(blobs_dir.join(&manifest_digest), &manifest_bytes)
1256        .map_err(|e| BoxError::BuildError(format!("Failed to write manifest blob: {}", e)))?;
1257
1258    // Write index.json
1259    let mut platform_obj = serde_json::json!({
1260        "os": target_platform.os,
1261        "architecture": target_platform.architecture
1262    });
1263    if let Some(ref variant) = target_platform.variant {
1264        platform_obj["variant"] = serde_json::json!(variant);
1265    }
1266
1267    let index = serde_json::json!({
1268        "schemaVersion": 2,
1269        "mediaType": "application/vnd.oci.image.index.v1+json",
1270        "manifests": [{
1271            "mediaType": "application/vnd.oci.image.manifest.v1+json",
1272            "digest": format!("sha256:{}", manifest_digest),
1273            "size": manifest_bytes.len(),
1274            "platform": platform_obj
1275        }]
1276    });
1277    std::fs::write(
1278        output_dir.join("index.json"),
1279        serde_json::to_string_pretty(&index)?,
1280    )
1281    .map_err(|e| BoxError::BuildError(format!("Failed to write index.json: {}", e)))?;
1282
1283    // Write oci-layout
1284    std::fs::write(
1285        output_dir.join("oci-layout"),
1286        r#"{"imageLayoutVersion":"1.0.0"}"#,
1287    )
1288    .map_err(|e| BoxError::BuildError(format!("Failed to write oci-layout: {}", e)))?;
1289
1290    // Store in image store
1291    let digest_str = format!("sha256:{}", manifest_digest);
1292    let stored = store.put(reference, &digest_str, &output_dir).await?;
1293
1294    let total_layers = base_layers.len() + state.layers.len();
1295
1296    Ok(BuildResult {
1297        reference: reference.to_string(),
1298        digest: digest_str,
1299        size: stored.size_bytes,
1300        layer_count: total_layers,
1301    })
1302}