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}