zlayer-builder 0.13.0

Dockerfile parsing and buildah-based container image building
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
//! Buildah-backed build backend.
//!
//! Wraps [`BuildahExecutor`] to implement the [`BuildBackend`] trait.
//!
//! The build path renders the parsed [`Dockerfile`] IR back to canonical
//! Dockerfile text (after build-arg expansion + default-cache-mount merge) and
//! drives buildah's NATIVE frontend (`buildah build -f <rendered>`), instead of
//! the legacy `buildah from` → per-instruction translate → `buildah commit`
//! loop. buildah's own parser/executor then handles stages, base-image
//! resolution, `COPY --from` (including external image refs), and layer caching.

use std::collections::BTreeMap;
use std::path::Path;
use std::sync::mpsc;

use tracing::{debug, info, warn};

use crate::buildah::{BuildahCommand, BuildahExecutor};
use crate::builder::{BuildOptions, BuiltImage, RegistryAuth};
use crate::dockerfile::{
    expand_dockerfile, forward_build_arg_env, merge_default_cache_mounts, render_dockerfile,
    Dockerfile, Instruction, RunMount,
};
use crate::error::{BuildError, Result};
use crate::tui::{BuildEvent, PlannedStage};

use super::progress::InstructionProgress;
use super::BuildBackend;

// ---------------------------------------------------------------------------
// BuildahBackend
// ---------------------------------------------------------------------------

/// Build backend that delegates to the `buildah` CLI.
pub struct BuildahBackend {
    executor: BuildahExecutor,
}

impl BuildahBackend {
    /// Try to create a new `BuildahBackend`.
    ///
    /// Returns `Ok` if buildah is found and functional, `Err` otherwise.
    ///
    /// # Errors
    ///
    /// Returns an error if buildah is not installed or is not responding.
    pub async fn try_new() -> Result<Self> {
        let executor = BuildahExecutor::new_async().await?;
        if !executor.is_available().await {
            return Err(crate::error::BuildError::BuildahNotFound {
                message: "buildah is installed but not responding".into(),
            });
        }
        Ok(Self { executor })
    }

    /// Create a new `BuildahBackend`, returning an error if buildah is not available.
    ///
    /// # Errors
    ///
    /// Returns an error if buildah is not installed or cannot be initialized.
    pub async fn new() -> Result<Self> {
        let executor = BuildahExecutor::new_async().await?;
        Ok(Self { executor })
    }

    /// Create a `BuildahBackend` from an existing executor.
    #[must_use]
    pub fn with_executor(executor: BuildahExecutor) -> Self {
        Self { executor }
    }

    /// Borrow the inner executor (useful for low-level operations).
    #[must_use]
    pub fn executor(&self) -> &BuildahExecutor {
        &self.executor
    }

    // -----------------------------------------------------------------------
    // Build helpers
    // -----------------------------------------------------------------------

    /// Tag an image with an additional tag.
    async fn tag_image_internal(&self, image: &str, tag: &str) -> Result<()> {
        let cmd = BuildahCommand::tag(image, tag);
        self.executor.execute_checked(&cmd).await?;
        Ok(())
    }

    /// Push an image to a registry.
    async fn push_image_internal(&self, tag: &str, auth: Option<&RegistryAuth>) -> Result<()> {
        // buildah requires options (`--creds`) BEFORE the positional image arg
        // (`buildah push [options] IMAGE`); appending them after the image
        // fails with "no options (--creds) can be specified after the image or
        // container name". `push_with_creds` orders the flags correctly.
        let creds = auth.map(|auth| format!("{}:{}", auth.username, auth.password));
        let cmd = BuildahCommand::push_with_creds(tag, creds.as_deref());
        self.executor.execute_checked(&cmd).await?;
        Ok(())
    }

    /// Send an event to the TUI (if configured).
    fn send_event(event_tx: Option<&mpsc::Sender<BuildEvent>>, event: BuildEvent) {
        if let Some(tx) = event_tx {
            let _ = tx.send(event);
        }
    }
}

#[async_trait::async_trait]
impl BuildBackend for BuildahBackend {
    #[allow(clippy::too_many_lines)]
    async fn build_image(
        &self,
        context: &Path,
        dockerfile: &Dockerfile,
        options: &BuildOptions,
        event_tx: Option<mpsc::Sender<BuildEvent>>,
    ) -> Result<BuiltImage> {
        let start_time = std::time::Instant::now();

        debug!(
            "BuildahBackend: starting build ({} stages)",
            dockerfile.stages.len()
        );

        // 1) Prepare the final IR.
        //
        // `effective_build_args` mirrors how the legacy loop sourced ARG
        // bindings: the explicit build args overlaid with pipeline vars. We
        // keep it sorted (BTreeMap) so `--build-arg` flags are deterministic.
        let mut effective_build_args: BTreeMap<String, String> = BTreeMap::new();
        for (k, v) in &options.build_args {
            effective_build_args.insert(k.clone(), v.clone());
        }
        for (k, v) in &options.pipeline_vars {
            effective_build_args.insert(k.clone(), v.clone());
        }
        // Docker `--build-arg FOO` (no `=value`) semantics: any build-arg DECLARED
        // in the IR (`ARG`) but left empty/unset is populated from a matching
        // non-empty process-env var. Applied before deriving the expansion map so
        // both the `--build-arg` flags and in-Dockerfile `${VAR}` references see it.
        forward_build_arg_env(dockerfile, &mut effective_build_args);

        // `expand_dockerfile` wants a HashMap; reuse the merged bindings.
        let expand_args: std::collections::HashMap<String, String> = effective_build_args
            .iter()
            .map(|(k, v)| (k.clone(), v.clone()))
            .collect();

        let mut ir = expand_dockerfile(dockerfile, &expand_args);
        merge_default_cache_mounts(&mut ir, options);
        let text = render_dockerfile(&ir);

        // Collect distinct RUN ssh ids from the final IR so we can pass
        // `--ssh <id>` for each. `buildah build` requires the ssh socket to be
        // authorized per-id; the bare `RUN --mount=type=ssh` (no id) maps to
        // the conventional `default` id.
        let ssh_ids = collect_ssh_ids(&ir);
        let secret_ids = collect_secret_ids(&ir);

        // 2) Write the rendered Dockerfile INSIDE the context dir so buildah can
        //    resolve the `-f` path AND so a context-less (ZImagefile-only) build
        //    still finds it. Keep the NamedTempFile alive until the build
        //    finishes (dropping it deletes the file).
        let mut rendered = tempfile::Builder::new()
            .prefix("zlayer-rendered-")
            .tempfile_in(context)
            .map_err(BuildError::from)?;
        {
            use std::io::Write as _;
            rendered
                .write_all(text.as_bytes())
                .map_err(BuildError::from)?;
            rendered.flush().map_err(BuildError::from)?;
        }
        let dockerfile_path = rendered.path().to_path_buf();

        // 3) Emit the up-front plan so the TUI has a stable denominator and a
        //    full instruction list. `format!("{instruction:?}")` matches the
        //    sidecar backend's planned-instruction text byte-for-byte.
        let planned_stages: Vec<PlannedStage> = ir
            .stages
            .iter()
            .map(|stage| PlannedStage {
                name: stage.name.clone(),
                base_image: stage.base_image.to_string(),
                instructions: stage
                    .instructions
                    .iter()
                    .map(|instruction| format!("{instruction:?}"))
                    .collect(),
            })
            .collect();

        let total_stages = planned_stages.len();
        let total_instructions: usize = planned_stages.iter().map(|s| s.instructions.len()).sum();

        Self::send_event(
            event_tx.as_ref(),
            BuildEvent::BuildStarted {
                total_stages,
                total_instructions,
            },
        );

        let mut progress = InstructionProgress::from_planned_stages(&planned_stages);
        Self::send_event(
            event_tx.as_ref(),
            BuildEvent::BuildPlan {
                stages: planned_stages,
            },
        );
        for event in progress.start_first() {
            Self::send_event(event_tx.as_ref(), event);
        }

        // 4) Run a single `buildah build`, wrapped in a whole-build retry loop
        //    honoring `options.retries` (replaces the old per-RUN retry).
        let cmd = BuildahCommand::build(
            &dockerfile_path,
            context,
            options,
            &effective_build_args,
            &ssh_ids,
            &secret_ids,
        );

        let max_attempts = options.retries + 1;
        let mut last_output = None;

        for attempt in 1..=max_attempts {
            if attempt > 1 {
                warn!("Retrying build (attempt {}/{})...", attempt, max_attempts);
                Self::send_event(
                    event_tx.as_ref(),
                    BuildEvent::Output {
                        line: format!("⟳ Retrying build (attempt {attempt}/{max_attempts})..."),
                        is_stderr: false,
                    },
                );
                tokio::time::sleep(std::time::Duration::from_secs(3)).await;
            }

            let event_tx_clone = event_tx.clone();
            let progress_ref = &mut progress;
            let output = self
                .executor
                .execute_streaming(&cmd, |is_stdout, line| {
                    // Feed each line to the shared progress cursor to reconstruct
                    // per-instruction events from buildah's commit markers, and
                    // forward every event (Output + progress) to the TUI.
                    let events = progress_ref.on_line(line, !is_stdout);
                    for event in events {
                        Self::send_event(event_tx_clone.as_ref(), event);
                    }
                })
                .await?;

            if output.success() {
                last_output = Some(output);
                break;
            }
            last_output = Some(output);
        }

        let output = last_output.expect("retry loop runs at least once");
        if !output.success() {
            Self::send_event(
                event_tx.as_ref(),
                BuildEvent::BuildFailed {
                    error: output.stderr.clone(),
                },
            );
            return Err(BuildError::buildah_execution(
                cmd.to_command_string(),
                output.exit_code,
                output.stderr,
            ));
        }

        // 5) Resolve the resulting image id. `buildah build --iidfile` would be
        //    cleaner, but we keep it simple: the image id is the last non-empty
        //    line buildah prints on stdout (the committed image SHA), and if
        //    tags were requested the first tag is the human-facing name.
        let image_id = output
            .stdout
            .lines()
            .rev()
            .map(str::trim)
            .find(|l| !l.is_empty())
            .map_or_else(
                || options.tags.first().cloned().unwrap_or_default(),
                ToString::to_string,
            );

        info!("Built image: {}", image_id);

        // Note: `buildah build` applies ALL `--tag`s itself, so there is no
        // separate tag-application step here (the legacy commit→tag loop is
        // gone). `tag_image_internal` remains for the `BuildBackend::tag_image`
        // trait method and ad-hoc retagging.

        // 6) Push if requested.
        if options.push {
            for tag in &options.tags {
                self.push_image_internal(tag, options.registry_auth.as_ref())
                    .await?;
                info!("Pushed image: {}", tag);
            }
        }

        #[allow(clippy::cast_possible_truncation)]
        let build_time_ms = start_time.elapsed().as_millis() as u64;

        Self::send_event(
            event_tx.as_ref(),
            BuildEvent::BuildComplete {
                image_id: image_id.clone(),
            },
        );

        info!(
            "Build completed in {}ms: {} with {} tags",
            build_time_ms,
            image_id,
            options.tags.len()
        );

        // Keep the rendered Dockerfile alive until here, then drop it.
        drop(rendered);

        Ok(BuiltImage {
            image_id,
            tags: options.tags.clone(),
            layer_count: total_instructions,
            size: 0, // buildah build does not report size; an inspect RPC would.
            build_time_ms,
            is_manifest: options.platform.as_deref().is_some_and(|s| s.contains(',')),
        })
    }

    async fn push_image(&self, tag: &str, auth: Option<&RegistryAuth>) -> Result<()> {
        self.push_image_internal(tag, auth).await
    }

    async fn tag_image(&self, image: &str, new_tag: &str) -> Result<()> {
        self.tag_image_internal(image, new_tag).await
    }

    async fn manifest_create(&self, name: &str) -> Result<()> {
        // Idempotent: clears any stale manifest list / plain image of this name
        // first so a re-run after a partial build doesn't hit "name already in
        // use" (exit 125). See `BuildahExecutor::manifest_create_idempotent`.
        self.executor.manifest_create_idempotent(name).await
    }

    async fn manifest_add(&self, manifest: &str, image: &str) -> Result<()> {
        let cmd = BuildahCommand::manifest_add(manifest, image);
        self.executor.execute_checked(&cmd).await?;
        Ok(())
    }

    async fn manifest_push(
        &self,
        name: &str,
        destination: &str,
        auth: Option<&RegistryAuth>,
    ) -> Result<()> {
        // Same ordering rule as `push`: options (`--creds`) must precede the
        // positional list/destination args. `manifest_push_with_creds` places
        // every flag before the positionals.
        let creds = auth.map(|auth| format!("{}:{}", auth.username, auth.password));
        let cmd = BuildahCommand::manifest_push_with_creds(name, destination, creds.as_deref());
        self.executor.execute_checked(&cmd).await?;
        Ok(())
    }

    async fn is_available(&self) -> bool {
        self.executor.is_available().await
    }

    fn name(&self) -> &'static str {
        "buildah"
    }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Collect the distinct `RUN --mount=type=ssh` ids across the whole IR, in
/// first-seen order, so the build can pass one `--ssh <id>` per id.
///
/// A bare `RUN --mount=type=ssh` (no `id=`) maps to the conventional `default`
/// id that `buildah build --ssh default` authorizes.
fn collect_ssh_ids(df: &Dockerfile) -> Vec<String> {
    let mut ids: Vec<String> = Vec::new();
    for stage in &df.stages {
        for instruction in &stage.instructions {
            let Instruction::Run(run) = instruction else {
                continue;
            };
            for mount in &run.mounts {
                if let RunMount::Ssh { id, .. } = mount {
                    let id = id.clone().unwrap_or_else(|| "default".to_string());
                    if !ids.contains(&id) {
                        ids.push(id);
                    }
                }
            }
        }
    }
    ids
}

fn collect_secret_ids(df: &Dockerfile) -> Vec<String> {
    let mut specs: Vec<String> = Vec::new();
    for stage in &df.stages {
        for instruction in &stage.instructions {
            let Instruction::Run(run) = instruction else {
                continue;
            };
            for mount in &run.mounts {
                if let RunMount::Secret { id, .. } = mount {
                    let spec = format!("id={id}");
                    if !specs.contains(&spec) {
                        specs.push(spec);
                    }
                }
            }
        }
    }
    specs
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::dockerfile::{CacheSharing, RunInstruction, RunMount};

    #[test]
    fn collect_ssh_ids_dedups_and_defaults() {
        let mut run_default = RunInstruction::shell("git fetch");
        run_default.mounts.push(RunMount::Ssh {
            target: String::new(),
            id: None,
            required: false,
        });

        let mut run_named = RunInstruction::shell("git fetch again");
        run_named.mounts.push(RunMount::Ssh {
            target: String::new(),
            id: Some("github".to_string()),
            required: true,
        });

        // A second bare ssh mount must NOT add a duplicate `default`.
        let mut run_default2 = RunInstruction::shell("git fetch thrice");
        run_default2.mounts.push(RunMount::Ssh {
            target: String::new(),
            id: None,
            required: false,
        });

        let df = Dockerfile::parse("FROM alpine\n").expect("trivial Dockerfile parses");
        let mut df = df;
        df.stages[0].instructions = vec![
            Instruction::Run(run_default),
            Instruction::Run(run_named),
            Instruction::Run(run_default2),
        ];

        assert_eq!(collect_ssh_ids(&df), vec!["default", "github"]);
    }

    #[test]
    fn collect_ssh_ids_empty_when_no_ssh_mounts() {
        let mut run = RunInstruction::shell("apt-get update");
        run.mounts.push(RunMount::Cache {
            target: "/var/cache/apt".to_string(),
            id: None,
            sharing: CacheSharing::Shared,
            readonly: false,
        });
        let mut df = Dockerfile::parse("FROM alpine\n").expect("trivial Dockerfile parses");
        df.stages[0].instructions = vec![Instruction::Run(run)];
        assert!(collect_ssh_ids(&df).is_empty());
    }
}