zlayer-builder 0.13.0

Dockerfile parsing and buildah-based container image building
Documentation
//! Shared buildah commit-marker → [`BuildEvent`] progress reconstruction.
//!
//! Buildah's frontend (`buildah build`) does not emit structured per-instruction
//! events; it prints exactly one commit marker line per executed instruction:
//! `--> <hex>` (a fresh layer) or `--> Using cache <hex>` (a cache hit). The
//! stage `FROM` line does NOT get a `-->` marker.
//!
//! [`InstructionProgress`] walks a pre-computed, flattened list of planned
//! instructions (in Dockerfile order, `FROM` excluded) and, as each marker
//! arrives on a log line, advances a cursor and reconstructs the matching
//! [`BuildEvent::InstructionComplete`] / [`BuildEvent::StageComplete`] /
//! next [`BuildEvent::InstructionStarted`] events. Non-marker lines are
//! forwarded verbatim as [`BuildEvent::Output`].
//!
//! This logic is shared between the buildah-sidecar backend (whose Go buildd
//! only streams `Log` lines) and the buildah-CLI backend (which runs a single
//! `buildah build` and parses its stdout/stderr the same way).

use crate::tui::{BuildEvent, PlannedStage};

/// Tracks build progress by parsing buildah commit markers out of the build's
/// log stream and reconstructing per-instruction TUI events.
///
/// Construct with [`InstructionProgress::new`] (a flattened
/// `(stage_idx, inst_idx, text)` list) or the convenience
/// [`InstructionProgress::from_planned_stages`]. Feed each log line through
/// [`InstructionProgress::on_line`]; it returns the [`BuildEvent`]s the caller
/// should forward.
pub struct InstructionProgress {
    /// Flattened `(stage_idx, inst_idx, instruction_text)` in Dockerfile order.
    items: Vec<(usize, usize, String)>,
    /// Index into `items` of the instruction currently `Running`.
    pos: usize,
}

impl InstructionProgress {
    /// Build a progress tracker from a flattened, ordered list of planned
    /// instructions: `(stage_idx, inst_idx, instruction_text)`. The list MUST
    /// be in Dockerfile order and exclude `FROM` lines (which get no commit
    /// marker).
    #[must_use]
    pub fn new(planned: Vec<(usize, usize, String)>) -> Self {
        Self {
            items: planned,
            pos: 0,
        }
    }

    /// Build a progress tracker from the planned stages, flattening each
    /// stage's instructions into `(stage_idx, inst_idx, text)` in order.
    #[must_use]
    pub fn from_planned_stages(stages: &[PlannedStage]) -> Self {
        let mut items = Vec::new();
        for (stage_idx, stage) in stages.iter().enumerate() {
            for (inst_idx, text) in stage.instructions.iter().enumerate() {
                items.push((stage_idx, inst_idx, text.clone()));
            }
        }
        Self::new(items)
    }

    /// The current `(stage, index)` if the cursor still points at a planned
    /// instruction.
    #[must_use]
    pub fn current(&self) -> Option<(usize, usize)> {
        self.items.get(self.pos).map(|(s, i, _)| (*s, *i))
    }

    /// The current instruction's text (empty string once exhausted).
    #[must_use]
    pub fn current_text(&self) -> &str {
        self.items.get(self.pos).map_or("", |(_, _, t)| t.as_str())
    }

    /// Emit the initial [`BuildEvent::InstructionStarted`] for the first planned
    /// instruction, if any. Call once after sending `BuildPlan`.
    #[must_use]
    pub fn start_first(&self) -> Vec<BuildEvent> {
        match self.current() {
            Some((stage, index)) => vec![BuildEvent::InstructionStarted {
                stage,
                index,
                instruction: self.current_text().to_string(),
            }],
            None => Vec::new(),
        }
    }

    /// Advance one instruction. Returns the `stage` of the instruction we were
    /// on (so callers can detect a stage boundary).
    fn advance(&mut self) -> Option<usize> {
        let prev_stage = self.items.get(self.pos).map(|(s, _, _)| *s);
        // Saturate at the end — never index past the planned instructions.
        if self.pos < self.items.len() {
            self.pos += 1;
        }
        prev_stage
    }

    /// Process a single log line.
    ///
    /// Always returns a [`BuildEvent::Output`] mirroring the raw line. When the
    /// line is a buildah commit marker (`--> ...`), it additionally advances the
    /// cursor and appends the matching [`BuildEvent::InstructionComplete`], an
    /// optional [`BuildEvent::StageComplete`] on a stage boundary, and the next
    /// [`BuildEvent::InstructionStarted`].
    #[must_use]
    pub fn on_line(&mut self, line: &str, is_stderr: bool) -> Vec<BuildEvent> {
        let mut events = vec![BuildEvent::Output {
            line: line.to_string(),
            is_stderr,
        }];

        // buildah emits one commit marker per executed instruction.
        let trimmed = line.trim_start();
        if !trimmed.starts_with("--> ") {
            return events;
        }
        let cached = trimmed.contains("Using cache");

        let Some((stage, index)) = self.current() else {
            // More `-->` lines than planned instructions: saturate, don't panic.
            return events;
        };

        events.push(BuildEvent::InstructionComplete {
            stage,
            index,
            cached,
        });

        let prev_stage = self.advance();
        let next = self.current();

        // Crossing into a new stage means the previous stage finished.
        if let Some(prev) = prev_stage {
            let entering_new_stage = next.is_none_or(|(s, _)| s != prev);
            if entering_new_stage {
                events.push(BuildEvent::StageComplete { index: prev });
            }
        }
        // Start the next instruction (if any remain).
        if let Some((stage, index)) = next {
            events.push(BuildEvent::InstructionStarted {
                stage,
                index,
                instruction: self.current_text().to_string(),
            });
        }

        events
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn plan_two_stages() -> Vec<PlannedStage> {
        vec![
            PlannedStage {
                name: Some("builder".to_string()),
                base_image: "alpine".to_string(),
                instructions: vec!["RUN echo a".to_string(), "RUN echo b".to_string()],
            },
            PlannedStage {
                name: None,
                base_image: "alpine".to_string(),
                instructions: vec!["COPY --from=builder /a /a".to_string()],
            },
        ]
    }

    #[test]
    fn cursor_flattens_in_dockerfile_order() {
        let progress = InstructionProgress::from_planned_stages(&plan_two_stages());
        assert_eq!(progress.current(), Some((0, 0)));
        assert_eq!(progress.current_text(), "RUN echo a");
        assert_eq!(progress.items.len(), 3);
    }

    #[test]
    fn commit_markers_advance_statuses_and_cross_stage_boundary() {
        let mut progress = InstructionProgress::from_planned_stages(&plan_two_stages());

        // Three executed instructions => three `--> ` markers. The middle one
        // uses the cache. FROM does NOT get a marker.
        let mut events = Vec::new();
        events.extend(progress.on_line("--> abc123", false));
        events.extend(progress.on_line("--> Using cache def456", false));
        events.extend(progress.on_line("--> 789aaa", false));
        // A 4th marker beyond the plan must saturate, not panic.
        events.extend(progress.on_line("--> extra", false));

        // Every line is forwarded verbatim as Output (4 lines).
        let outputs = events
            .iter()
            .filter(|e| matches!(e, BuildEvent::Output { .. }))
            .count();
        assert_eq!(outputs, 4);

        // Three completions, the 2nd cached.
        let completes: Vec<_> = events
            .iter()
            .filter_map(|e| match e {
                BuildEvent::InstructionComplete {
                    stage,
                    index,
                    cached,
                } => Some((*stage, *index, *cached)),
                _ => None,
            })
            .collect();
        assert_eq!(completes, vec![(0, 0, false), (0, 1, true), (1, 0, false)]);

        // Stage 0 completes when crossing into stage 1 (after inst (0,1));
        // stage 1 completes when its last instruction (1,0) commits and the
        // cursor runs off the end (next is None).
        let stage_completes: Vec<_> = events
            .iter()
            .filter_map(|e| match e {
                BuildEvent::StageComplete { index } => Some(*index),
                _ => None,
            })
            .collect();
        assert_eq!(stage_completes, vec![0, 1]);

        // InstructionStarted is emitted for the next instruction after each
        // advance: (0,1) then (1,0). No start past the end (saturated).
        let starts: Vec<_> = events
            .iter()
            .filter_map(|e| match e {
                BuildEvent::InstructionStarted { stage, index, .. } => Some((*stage, *index)),
                _ => None,
            })
            .collect();
        assert_eq!(starts, vec![(0, 1), (1, 0)]);
    }

    #[test]
    fn non_marker_log_lines_do_not_advance_cursor() {
        let mut progress = InstructionProgress::from_planned_stages(&plan_two_stages());

        let mut events = Vec::new();
        events.extend(progress.on_line("STEP 1/3: RUN echo a", false));
        events.extend(progress.on_line("some build output", true));

        // Both forwarded as Output, but no progress events.
        assert_eq!(events.len(), 2);
        assert!(events
            .iter()
            .all(|e| matches!(e, BuildEvent::Output { .. })));
        // Cursor unmoved.
        assert_eq!(progress.current(), Some((0, 0)));
    }
}