Skip to main content

zlayer_builder/backend/
progress.rs

1//! Shared buildah commit-marker → [`BuildEvent`] progress reconstruction.
2//!
3//! Buildah's frontend (`buildah build`) does not emit structured per-instruction
4//! events; it prints exactly one commit marker line per executed instruction:
5//! `--> <hex>` (a fresh layer) or `--> Using cache <hex>` (a cache hit). The
6//! stage `FROM` line does NOT get a `-->` marker.
7//!
8//! [`InstructionProgress`] walks a pre-computed, flattened list of planned
9//! instructions (in Dockerfile order, `FROM` excluded) and, as each marker
10//! arrives on a log line, advances a cursor and reconstructs the matching
11//! [`BuildEvent::InstructionComplete`] / [`BuildEvent::StageComplete`] /
12//! next [`BuildEvent::InstructionStarted`] events. Non-marker lines are
13//! forwarded verbatim as [`BuildEvent::Output`].
14//!
15//! This logic is shared between the buildah-sidecar backend (whose Go buildd
16//! only streams `Log` lines) and the buildah-CLI backend (which runs a single
17//! `buildah build` and parses its stdout/stderr the same way).
18
19use crate::tui::{BuildEvent, PlannedStage};
20
21/// Tracks build progress by parsing buildah commit markers out of the build's
22/// log stream and reconstructing per-instruction TUI events.
23///
24/// Construct with [`InstructionProgress::new`] (a flattened
25/// `(stage_idx, inst_idx, text)` list) or the convenience
26/// [`InstructionProgress::from_planned_stages`]. Feed each log line through
27/// [`InstructionProgress::on_line`]; it returns the [`BuildEvent`]s the caller
28/// should forward.
29pub struct InstructionProgress {
30    /// Flattened `(stage_idx, inst_idx, instruction_text)` in Dockerfile order.
31    items: Vec<(usize, usize, String)>,
32    /// Index into `items` of the instruction currently `Running`.
33    pos: usize,
34}
35
36impl InstructionProgress {
37    /// Build a progress tracker from a flattened, ordered list of planned
38    /// instructions: `(stage_idx, inst_idx, instruction_text)`. The list MUST
39    /// be in Dockerfile order and exclude `FROM` lines (which get no commit
40    /// marker).
41    #[must_use]
42    pub fn new(planned: Vec<(usize, usize, String)>) -> Self {
43        Self {
44            items: planned,
45            pos: 0,
46        }
47    }
48
49    /// Build a progress tracker from the planned stages, flattening each
50    /// stage's instructions into `(stage_idx, inst_idx, text)` in order.
51    #[must_use]
52    pub fn from_planned_stages(stages: &[PlannedStage]) -> Self {
53        let mut items = Vec::new();
54        for (stage_idx, stage) in stages.iter().enumerate() {
55            for (inst_idx, text) in stage.instructions.iter().enumerate() {
56                items.push((stage_idx, inst_idx, text.clone()));
57            }
58        }
59        Self::new(items)
60    }
61
62    /// The current `(stage, index)` if the cursor still points at a planned
63    /// instruction.
64    #[must_use]
65    pub fn current(&self) -> Option<(usize, usize)> {
66        self.items.get(self.pos).map(|(s, i, _)| (*s, *i))
67    }
68
69    /// The current instruction's text (empty string once exhausted).
70    #[must_use]
71    pub fn current_text(&self) -> &str {
72        self.items.get(self.pos).map_or("", |(_, _, t)| t.as_str())
73    }
74
75    /// Emit the initial [`BuildEvent::InstructionStarted`] for the first planned
76    /// instruction, if any. Call once after sending `BuildPlan`.
77    #[must_use]
78    pub fn start_first(&self) -> Vec<BuildEvent> {
79        match self.current() {
80            Some((stage, index)) => vec![BuildEvent::InstructionStarted {
81                stage,
82                index,
83                instruction: self.current_text().to_string(),
84            }],
85            None => Vec::new(),
86        }
87    }
88
89    /// Advance one instruction. Returns the `stage` of the instruction we were
90    /// on (so callers can detect a stage boundary).
91    fn advance(&mut self) -> Option<usize> {
92        let prev_stage = self.items.get(self.pos).map(|(s, _, _)| *s);
93        // Saturate at the end — never index past the planned instructions.
94        if self.pos < self.items.len() {
95            self.pos += 1;
96        }
97        prev_stage
98    }
99
100    /// Process a single log line.
101    ///
102    /// Always returns a [`BuildEvent::Output`] mirroring the raw line. When the
103    /// line is a buildah commit marker (`--> ...`), it additionally advances the
104    /// cursor and appends the matching [`BuildEvent::InstructionComplete`], an
105    /// optional [`BuildEvent::StageComplete`] on a stage boundary, and the next
106    /// [`BuildEvent::InstructionStarted`].
107    #[must_use]
108    pub fn on_line(&mut self, line: &str, is_stderr: bool) -> Vec<BuildEvent> {
109        let mut events = vec![BuildEvent::Output {
110            line: line.to_string(),
111            is_stderr,
112        }];
113
114        // buildah emits one commit marker per executed instruction.
115        let trimmed = line.trim_start();
116        if !trimmed.starts_with("--> ") {
117            return events;
118        }
119        let cached = trimmed.contains("Using cache");
120
121        let Some((stage, index)) = self.current() else {
122            // More `-->` lines than planned instructions: saturate, don't panic.
123            return events;
124        };
125
126        events.push(BuildEvent::InstructionComplete {
127            stage,
128            index,
129            cached,
130        });
131
132        let prev_stage = self.advance();
133        let next = self.current();
134
135        // Crossing into a new stage means the previous stage finished.
136        if let Some(prev) = prev_stage {
137            let entering_new_stage = next.is_none_or(|(s, _)| s != prev);
138            if entering_new_stage {
139                events.push(BuildEvent::StageComplete { index: prev });
140            }
141        }
142        // Start the next instruction (if any remain).
143        if let Some((stage, index)) = next {
144            events.push(BuildEvent::InstructionStarted {
145                stage,
146                index,
147                instruction: self.current_text().to_string(),
148            });
149        }
150
151        events
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    fn plan_two_stages() -> Vec<PlannedStage> {
160        vec![
161            PlannedStage {
162                name: Some("builder".to_string()),
163                base_image: "alpine".to_string(),
164                instructions: vec!["RUN echo a".to_string(), "RUN echo b".to_string()],
165            },
166            PlannedStage {
167                name: None,
168                base_image: "alpine".to_string(),
169                instructions: vec!["COPY --from=builder /a /a".to_string()],
170            },
171        ]
172    }
173
174    #[test]
175    fn cursor_flattens_in_dockerfile_order() {
176        let progress = InstructionProgress::from_planned_stages(&plan_two_stages());
177        assert_eq!(progress.current(), Some((0, 0)));
178        assert_eq!(progress.current_text(), "RUN echo a");
179        assert_eq!(progress.items.len(), 3);
180    }
181
182    #[test]
183    fn commit_markers_advance_statuses_and_cross_stage_boundary() {
184        let mut progress = InstructionProgress::from_planned_stages(&plan_two_stages());
185
186        // Three executed instructions => three `--> ` markers. The middle one
187        // uses the cache. FROM does NOT get a marker.
188        let mut events = Vec::new();
189        events.extend(progress.on_line("--> abc123", false));
190        events.extend(progress.on_line("--> Using cache def456", false));
191        events.extend(progress.on_line("--> 789aaa", false));
192        // A 4th marker beyond the plan must saturate, not panic.
193        events.extend(progress.on_line("--> extra", false));
194
195        // Every line is forwarded verbatim as Output (4 lines).
196        let outputs = events
197            .iter()
198            .filter(|e| matches!(e, BuildEvent::Output { .. }))
199            .count();
200        assert_eq!(outputs, 4);
201
202        // Three completions, the 2nd cached.
203        let completes: Vec<_> = events
204            .iter()
205            .filter_map(|e| match e {
206                BuildEvent::InstructionComplete {
207                    stage,
208                    index,
209                    cached,
210                } => Some((*stage, *index, *cached)),
211                _ => None,
212            })
213            .collect();
214        assert_eq!(completes, vec![(0, 0, false), (0, 1, true), (1, 0, false)]);
215
216        // Stage 0 completes when crossing into stage 1 (after inst (0,1));
217        // stage 1 completes when its last instruction (1,0) commits and the
218        // cursor runs off the end (next is None).
219        let stage_completes: Vec<_> = events
220            .iter()
221            .filter_map(|e| match e {
222                BuildEvent::StageComplete { index } => Some(*index),
223                _ => None,
224            })
225            .collect();
226        assert_eq!(stage_completes, vec![0, 1]);
227
228        // InstructionStarted is emitted for the next instruction after each
229        // advance: (0,1) then (1,0). No start past the end (saturated).
230        let starts: Vec<_> = events
231            .iter()
232            .filter_map(|e| match e {
233                BuildEvent::InstructionStarted { stage, index, .. } => Some((*stage, *index)),
234                _ => None,
235            })
236            .collect();
237        assert_eq!(starts, vec![(0, 1), (1, 0)]);
238    }
239
240    #[test]
241    fn non_marker_log_lines_do_not_advance_cursor() {
242        let mut progress = InstructionProgress::from_planned_stages(&plan_two_stages());
243
244        let mut events = Vec::new();
245        events.extend(progress.on_line("STEP 1/3: RUN echo a", false));
246        events.extend(progress.on_line("some build output", true));
247
248        // Both forwarded as Output, but no progress events.
249        assert_eq!(events.len(), 2);
250        assert!(events
251            .iter()
252            .all(|e| matches!(e, BuildEvent::Output { .. })));
253        // Cursor unmoved.
254        assert_eq!(progress.current(), Some((0, 0)));
255    }
256}