Skip to main content

yah_qed/images/
compile.rs

1//! TOML → Dockerfile compiler.
2//!
3//! Takes a [`CatalogEntry`](super::CatalogEntry) (plus the surrounding
4//! [`CatalogManifest`](super::CatalogManifest) for `extends` validation) and
5//! emits the Dockerfile text that the build-image step kind (R381-T2) will
6//! hand to `docker buildx` (T4) or BuildKit-in-containerd (T5).
7//!
8//! Two paths:
9//!
10//! - [`compile_entry`] — pure TOML layering. Emits a Dockerfile string from
11//!   `base` / `extends` / `apt` / `pip` / `env`. The escape hatch is a
12//!   sibling Dockerfile loaded by [`compile_with_dockerfile_dir`].
13//!
14//! - [`compile_with_dockerfile_dir`] — if `<dir>/Dockerfile` exists, return
15//!   its contents verbatim (with a `FROM` line prepended when the user's
16//!   Dockerfile has none and the TOML sets `extends`). Otherwise falls back
17//!   to [`compile_entry`].
18
19use std::collections::HashSet;
20use std::path::Path;
21use thiserror::Error;
22
23use super::catalog::{CatalogEntry, CatalogManifest};
24
25/// Max length of an `extends` chain before the compiler aborts.
26/// W148 lists 5 as the default ceiling.
27pub const MAX_EXTENDS_DEPTH: usize = 5;
28
29#[derive(Error, Debug)]
30pub enum CompileError {
31    #[error("catalog entry `{name}` extends unknown image `{target}`")]
32    ExtendsNotFound { name: String, target: String },
33    #[error("catalog entry `{name}` has a cyclic extends chain: {}", chain.join(" → "))]
34    ExtendsCycle { name: String, chain: Vec<String> },
35    #[error("catalog entry `{name}` extends chain is deeper than the {max}-step limit")]
36    ExtendsTooDeep { name: String, max: usize },
37    #[error("catalog entry `{0}` has neither `base` nor `extends`")]
38    NoBase(String),
39    #[error("io error reading sibling Dockerfile at {path}: {source}")]
40    DockerfileIo {
41        path: String,
42        source: std::io::Error,
43    },
44}
45
46/// The published image reference for a catalog entry — what other Dockerfiles
47/// `FROM` when they extend it. Defaults to `ghcr.io/yah-ai/yah-<name>:latest`;
48/// release-time digest injection replaces `:latest` with `@sha256:...` (T8).
49pub fn catalog_image_ref(entry_name: &str) -> String {
50    format!("ghcr.io/yah-ai/{entry_name}:latest")
51}
52
53/// Where a catalog entry's build context can live, relative to the camp root,
54/// most-specific first (R633).
55///
56/// Per-camp entries own the first slot — a camp that drops a Dockerfile at
57/// `.yah/qed/images/<name>/` is overriding, and must win. The other two are the
58/// **bundled** entries' own source directories: the catalog manifest is
59/// `include_str!`'d into the binary, but the Dockerfiles it describes are plain
60/// files in the qed crate, and nothing looked for them. That is not a cosmetic
61/// gap — before this list existed, building `rusty-v8-musl-builder` through qed
62/// silently produced a bare `FROM alpine:edge` image (the layering shorthand)
63/// instead of the toolbox image the entry documents, because the only Dockerfile
64/// lookup was the per-camp one and the entry declares no `apt`/`env` shorthand
65/// at all. The image was only ever built by pointing `docker build` at this path
66/// by hand from `.github/workflows/`.
67///
68/// Both spellings are listed because this crate is developed inside the yah
69/// monorepo at `oss/qed/` and exported standalone; the same source tree answers
70/// to both prefixes depending on which root you are standing in.
71pub const IMAGE_DIR_SEARCH_PATH: &[&str] = &[
72    ".yah/qed/images",
73    // yah monorepo (this crate vendored under oss/).
74    "oss/qed/crates/qed/images",
75    // standalone qed checkout.
76    "crates/qed/images",
77];
78
79/// First directory on [`IMAGE_DIR_SEARCH_PATH`] that holds a `Dockerfile` for
80/// `entry_name`, **relative to `camp_root`** — or `None` when the entry is
81/// layering-shorthand only (or the binary is running outside any source tree
82/// that carries the contexts).
83///
84/// Relative because the result is used two ways: joined onto the camp root for
85/// I/O, and stored as a step's `context` (which the runner itself joins onto the
86/// camp root). Returning the absolute path would make the second use double-join
87/// or force the caller to un-join it.
88///
89/// The returned directory is both the Dockerfile's location *and* the build
90/// context: `rusty-v8-musl-builder`'s Dockerfile `COPY build-v8.sh`s from its
91/// own directory, so resolving one without the other builds an image missing
92/// the script that is the entire point of it.
93pub fn resolve_image_dir(camp_root: &Path, entry_name: &str) -> Option<std::path::PathBuf> {
94    IMAGE_DIR_SEARCH_PATH
95        .iter()
96        .map(|rel| Path::new(rel).join(entry_name))
97        .find(|rel| camp_root.join(rel).join("Dockerfile").is_file())
98}
99
100/// Generate a Dockerfile from a [`CatalogEntry`]'s layering shorthand.
101///
102/// - Entries with `base` start `FROM <base>` (the upstream image).
103/// - Entries with `extends` start `FROM ghcr.io/yah-ai/yah-<target>:latest`
104///   (the already-built catalog image).
105/// - `apt` becomes a single `RUN apt-get install --no-install-recommends …`
106///   layer (one cache layer, no per-package fragmentation).
107/// - `pip` becomes a single `RUN pip install --no-cache-dir …` layer.
108/// - `env` becomes one `ENV` line per pair (sorted for reproducibility).
109///
110/// Returns an error if the entry is orphan (no base, no extends), or if
111/// the extends chain has a cycle / exceeds [`MAX_EXTENDS_DEPTH`].
112pub fn compile_entry(
113    entry: &CatalogEntry,
114    catalog: &CatalogManifest,
115) -> Result<String, CompileError> {
116    validate_extends_chain(entry, catalog)?;
117    Ok(emit_dockerfile(entry))
118}
119
120/// Compile in a per-camp directory context: if `<dir>/Dockerfile` exists,
121/// return it verbatim. When the user's Dockerfile has no `FROM` line and the
122/// catalog entry sets `extends`, the compiler prepends one — this lets a
123/// camp drop a partial Dockerfile next to `image.toml` and still inherit
124/// from the catalog. TOML `apt` / `pip` / `env` are ignored when a Dockerfile
125/// is present (the user is overriding the shorthand).
126///
127/// Falls back to [`compile_entry`] when no sibling Dockerfile exists.
128pub fn compile_with_dockerfile_dir(
129    entry: &CatalogEntry,
130    catalog: &CatalogManifest,
131    dir: &Path,
132) -> Result<String, CompileError> {
133    let dockerfile_path = dir.join("Dockerfile");
134    if !dockerfile_path.is_file() {
135        return compile_entry(entry, catalog);
136    }
137
138    // Extends still must validate even when the user supplies a Dockerfile —
139    // if they reference an unknown parent in TOML we want to fail fast.
140    validate_extends_chain(entry, catalog)?;
141
142    let contents =
143        std::fs::read_to_string(&dockerfile_path).map_err(|e| CompileError::DockerfileIo {
144            path: dockerfile_path.display().to_string(),
145            source: e,
146        })?;
147
148    if has_from_line(&contents) {
149        return Ok(contents);
150    }
151
152    // No FROM line in the user's Dockerfile. If the TOML sets extends or
153    // base, prepend our resolved FROM line. Otherwise fall through to the
154    // raw contents (the user is on their own — docker will fail at build
155    // time, which is the same failure mode they'd see writing this by hand).
156    if let Some(from_line) = from_line_for(entry) {
157        Ok(format!("{from_line}\n\n{contents}"))
158    } else {
159        Ok(contents)
160    }
161}
162
163// ─── Internals ────────────────────────────────────────────────────────────────
164
165/// Walk the `extends` chain looking for cycles, depth violations, or dangling
166/// references. Returns `Ok(())` if the chain is well-formed (including the
167/// degenerate case where `entry.extends` is `None`).
168fn validate_extends_chain(
169    entry: &CatalogEntry,
170    catalog: &CatalogManifest,
171) -> Result<(), CompileError> {
172    let mut chain = vec![entry.name.clone()];
173    let mut seen: HashSet<String> = HashSet::new();
174    seen.insert(entry.name.clone());
175    let mut cursor: &CatalogEntry = entry;
176
177    while let Some(parent_name) = cursor.extends.as_deref() {
178        if !seen.insert(parent_name.to_string()) {
179            chain.push(parent_name.to_string());
180            return Err(CompileError::ExtendsCycle {
181                name: entry.name.clone(),
182                chain,
183            });
184        }
185        chain.push(parent_name.to_string());
186        if chain.len() > MAX_EXTENDS_DEPTH {
187            return Err(CompileError::ExtendsTooDeep {
188                name: entry.name.clone(),
189                max: MAX_EXTENDS_DEPTH,
190            });
191        }
192        cursor = catalog
193            .get(parent_name)
194            .ok_or_else(|| CompileError::ExtendsNotFound {
195                name: entry.name.clone(),
196                target: parent_name.to_string(),
197            })?;
198    }
199
200    // Reached an entry with no `extends`. It must have a `base` — otherwise
201    // the catalog has an orphan that the loader's validate() should have
202    // rejected, but be defensive here too.
203    if cursor.base.is_none() {
204        return Err(CompileError::NoBase(cursor.name.clone()));
205    }
206    Ok(())
207}
208
209/// Resolve the `FROM` line for an entry's *own* Dockerfile (not the chain).
210fn from_line_for(entry: &CatalogEntry) -> Option<String> {
211    if let Some(parent) = &entry.extends {
212        return Some(format!("FROM {}", catalog_image_ref(parent)));
213    }
214    entry.base.as_ref().map(|b| format!("FROM {b}"))
215}
216
217fn has_from_line(dockerfile: &str) -> bool {
218    dockerfile.lines().any(|l| {
219        let trimmed = l.trim_start();
220        // Skip comments and the optional `# syntax=` directive.
221        if trimmed.starts_with('#') || trimmed.is_empty() {
222            return false;
223        }
224        trimmed
225            .split_whitespace()
226            .next()
227            .map(|w| w.eq_ignore_ascii_case("FROM"))
228            .unwrap_or(false)
229    })
230}
231
232fn emit_dockerfile(entry: &CatalogEntry) -> String {
233    let mut lines: Vec<String> = Vec::new();
234    lines.push("# syntax=docker/dockerfile:1".to_string());
235    lines.push(format!(
236        "# Generated by qed::images::compile for catalog entry `{}`.",
237        entry.name
238    ));
239    if let Some(from) = from_line_for(entry) {
240        lines.push(from);
241    }
242
243    if !entry.apt.is_empty() {
244        let mut pkgs = entry.apt.clone();
245        pkgs.sort();
246        lines.push(format!(
247            "RUN apt-get update \\\n    && apt-get install -y --no-install-recommends \\\n        {} \\\n    && rm -rf /var/lib/apt/lists/*",
248            pkgs.join(" \\\n        ")
249        ));
250    }
251
252    if !entry.pip.is_empty() {
253        let mut pkgs = entry.pip.clone();
254        pkgs.sort();
255        lines.push(format!(
256            "RUN pip install --no-cache-dir \\\n        {}",
257            pkgs.join(" \\\n        ")
258        ));
259    }
260
261    if !entry.env.is_empty() {
262        let mut pairs: Vec<(&String, &String)> = entry.env.iter().collect();
263        pairs.sort_by(|a, b| a.0.cmp(b.0));
264        for (k, v) in pairs {
265            lines.push(format!("ENV {k}={v}"));
266        }
267    }
268
269    // Trailing newline matches what docker buildx expects from a Dockerfile.
270    let mut out = lines.join("\n");
271    out.push('\n');
272    out
273}
274
275// ─── Tests ────────────────────────────────────────────────────────────────────
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use std::collections::HashMap;
281    use std::fs;
282    use tempfile::tempdir;
283
284    fn entry(name: &str, base: Option<&str>, extends: Option<&str>) -> CatalogEntry {
285        CatalogEntry {
286            name: name.into(),
287            base: base.map(Into::into),
288            extends: extends.map(Into::into),
289            description: format!("{name} test fixture"),
290            tools: Vec::new(),
291            digests: HashMap::new(),
292            apt: Vec::new(),
293            pip: Vec::new(),
294            env: HashMap::new(),
295            produces: vec![crate::images::ProduceTarget::OciImage],
296        }
297    }
298
299    fn bundled() -> CatalogManifest {
300        CatalogManifest::bundled().unwrap()
301    }
302
303    #[test]
304    fn pure_toml_layering_yah_rust_pg() {
305        // The W148 worked example.
306        let mut e = entry("yah-rust-pg", None, Some("yah-rust"));
307        e.apt = vec!["postgresql-client".into(), "libpq-dev".into()];
308        e.env = HashMap::from([("PGUSER".into(), "yah".into())]);
309        let dockerfile = compile_entry(&e, &bundled()).unwrap();
310        assert!(
311            dockerfile.contains("FROM ghcr.io/yah-ai/yah-rust:latest"),
312            "missing FROM: {dockerfile}"
313        );
314        assert!(
315            dockerfile.contains("apt-get install"),
316            "missing apt: {dockerfile}"
317        );
318        assert!(
319            dockerfile.contains("libpq-dev"),
320            "missing package: {dockerfile}"
321        );
322        assert!(
323            dockerfile.contains("postgresql-client"),
324            "missing package: {dockerfile}"
325        );
326        assert!(
327            dockerfile.contains("ENV PGUSER=yah"),
328            "missing env: {dockerfile}"
329        );
330    }
331
332    #[test]
333    fn pure_toml_pip_layering() {
334        let mut e = entry("ml-runner", None, Some("yah-python"));
335        e.pip = vec!["numpy".into(), "scipy".into(), "pandas".into()];
336        let dockerfile = compile_entry(&e, &bundled()).unwrap();
337        assert!(dockerfile.contains("FROM ghcr.io/yah-ai/yah-python:latest"));
338        assert!(dockerfile.contains("pip install --no-cache-dir"));
339        assert!(dockerfile.contains("numpy"));
340        assert!(dockerfile.contains("pandas"));
341    }
342
343    #[test]
344    fn base_only_entry_emits_from_base() {
345        let e = entry("custom-base", Some("alpine:3.20"), None);
346        let dockerfile = compile_entry(&e, &bundled()).unwrap();
347        assert!(dockerfile.contains("FROM alpine:3.20"));
348    }
349
350    #[test]
351    fn extends_chain_validates_root_has_base() {
352        // yah-rust-bun → yah-rust → (base: rust:1-slim-bookworm). Should be fine.
353        let e = bundled().get("yah-rust-bun").unwrap().clone();
354        compile_entry(&e, &bundled()).unwrap();
355    }
356
357    #[test]
358    fn extends_unknown_target_rejected() {
359        let e = entry("orphan", None, Some("does-not-exist"));
360        let err = compile_entry(&e, &bundled()).unwrap_err();
361        assert!(
362            matches!(err, CompileError::ExtendsNotFound { ref target, .. } if target == "does-not-exist")
363        );
364    }
365
366    #[test]
367    fn extends_cycle_detected() {
368        // Build a synthetic catalog with a→b→a.
369        let a = {
370            let mut e = entry("a", None, Some("b"));
371            e.description = "cycle a".into();
372            e
373        };
374        let b = entry("b", None, Some("a"));
375        // Build a CatalogManifest from synthetic TOML so we exercise the
376        // public loader rather than poking internals.
377        let dir = tempdir().unwrap();
378        fs::write(
379            dir.path().join("a.toml"),
380            r#"
381[image]
382name        = "a"
383extends     = "b"
384description = "cycle a"
385"#,
386        )
387        .unwrap();
388        fs::write(
389            dir.path().join("b.toml"),
390            r#"
391[image]
392name        = "b"
393extends     = "a"
394description = "cycle b"
395"#,
396        )
397        .unwrap();
398        let manifest = CatalogManifest::load(dir.path()).unwrap();
399        let err = compile_entry(&a, &manifest).unwrap_err();
400        match err {
401            CompileError::ExtendsCycle { name, chain } => {
402                assert_eq!(name, "a");
403                assert_eq!(chain.first().map(String::as_str), Some("a"));
404                assert!(
405                    chain.iter().filter(|n| *n == "a").count() >= 2,
406                    "cycle chain shows return: {chain:?}"
407                );
408            }
409            other => panic!("expected ExtendsCycle, got {other:?}"),
410        }
411        // Touch b so it's not unused.
412        assert_eq!(b.name, "b");
413    }
414
415    #[test]
416    fn extends_depth_limit_enforced() {
417        // Build a→b→c→d→e→f (6 deep — exceeds MAX_EXTENDS_DEPTH = 5).
418        let dir = tempdir().unwrap();
419        for (name, parent) in [
420            ("a", "b"),
421            ("b", "c"),
422            ("c", "d"),
423            ("d", "e"),
424            ("e", "f"),
425            ("f", "g"),
426        ] {
427            fs::write(
428                dir.path().join(format!("{name}.toml")),
429                format!(
430                    r#"
431[image]
432name        = "{name}"
433extends     = "{parent}"
434description = "depth fixture"
435"#
436                ),
437            )
438            .unwrap();
439        }
440        fs::write(
441            dir.path().join("g.toml"),
442            r#"
443[image]
444name        = "g"
445base        = "alpine:3.20"
446description = "root"
447"#,
448        )
449        .unwrap();
450        let manifest = CatalogManifest::load(dir.path()).unwrap();
451        let a = manifest.get("a").unwrap().clone();
452        let err = compile_entry(&a, &manifest).unwrap_err();
453        assert!(
454            matches!(err, CompileError::ExtendsTooDeep { ref name, max } if name == "a" && max == MAX_EXTENDS_DEPTH),
455            "got: {err:?}"
456        );
457    }
458
459    #[test]
460    fn sibling_dockerfile_returned_verbatim_when_from_present() {
461        let dir = tempdir().unwrap();
462        fs::write(
463            dir.path().join("Dockerfile"),
464            "FROM debian:bookworm-slim\nRUN echo hi\n",
465        )
466        .unwrap();
467        let e = entry("custom", None, Some("yah-base"));
468        let out = compile_with_dockerfile_dir(&e, &bundled(), dir.path()).unwrap();
469        assert_eq!(out, "FROM debian:bookworm-slim\nRUN echo hi\n");
470    }
471
472    #[test]
473    fn sibling_dockerfile_gets_from_prefix_when_missing() {
474        let dir = tempdir().unwrap();
475        fs::write(dir.path().join("Dockerfile"), "RUN echo hi\n").unwrap();
476        let e = entry("custom", None, Some("yah-base"));
477        let out = compile_with_dockerfile_dir(&e, &bundled(), dir.path()).unwrap();
478        assert!(
479            out.starts_with("FROM ghcr.io/yah-ai/yah-base:latest"),
480            "missing prepended FROM: {out}"
481        );
482        assert!(out.contains("RUN echo hi"));
483    }
484
485    #[test]
486    fn sibling_dockerfile_with_comment_only_still_gets_prefix() {
487        // A `# syntax=` directive should not be mistaken for a FROM line.
488        let dir = tempdir().unwrap();
489        fs::write(
490            dir.path().join("Dockerfile"),
491            "# syntax=docker/dockerfile:1\nRUN echo hi\n",
492        )
493        .unwrap();
494        let e = entry("custom", None, Some("yah-base"));
495        let out = compile_with_dockerfile_dir(&e, &bundled(), dir.path()).unwrap();
496        assert!(
497            out.starts_with("FROM ghcr.io/yah-ai/yah-base:latest"),
498            "syntax directive shouldn't count as FROM: {out}"
499        );
500    }
501
502    #[test]
503    fn dir_without_dockerfile_falls_back_to_toml_layering() {
504        let dir = tempdir().unwrap();
505        let mut e = entry("layered", None, Some("yah-base"));
506        e.apt = vec!["jq".into()];
507        let out = compile_with_dockerfile_dir(&e, &bundled(), dir.path()).unwrap();
508        assert!(out.contains("FROM ghcr.io/yah-ai/yah-base:latest"));
509        assert!(out.contains("jq"));
510    }
511
512    #[test]
513    fn sibling_dockerfile_with_invalid_extends_still_rejected() {
514        // The Dockerfile escape hatch doesn't excuse a bogus extends — the
515        // user has explicitly referenced a parent and we should catch typos.
516        let dir = tempdir().unwrap();
517        fs::write(dir.path().join("Dockerfile"), "FROM scratch\n").unwrap();
518        let e = entry("typo", None, Some("yah-rsut")); // 'rsut' typo
519        let err = compile_with_dockerfile_dir(&e, &bundled(), dir.path()).unwrap_err();
520        assert!(
521            matches!(err, CompileError::ExtendsNotFound { ref target, .. } if target == "yah-rsut")
522        );
523    }
524}