1use std::collections::HashSet;
20use std::path::Path;
21use thiserror::Error;
22
23use super::catalog::{CatalogEntry, CatalogManifest};
24
25pub 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
46pub fn catalog_image_ref(entry_name: &str) -> String {
50 format!("ghcr.io/yah-ai/{entry_name}:latest")
51}
52
53pub const IMAGE_DIR_SEARCH_PATH: &[&str] = &[
72 ".yah/qed/images",
73 "oss/qed/crates/qed/images",
75 "crates/qed/images",
77];
78
79pub 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
100pub 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
120pub 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 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 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
163fn 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 if cursor.base.is_none() {
204 return Err(CompileError::NoBase(cursor.name.clone()));
205 }
206 Ok(())
207}
208
209fn 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 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 let mut out = lines.join("\n");
271 out.push('\n');
272 out
273}
274
275#[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 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 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 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 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 assert_eq!(b.name, "b");
413 }
414
415 #[test]
416 fn extends_depth_limit_enforced() {
417 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 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 let dir = tempdir().unwrap();
517 fs::write(dir.path().join("Dockerfile"), "FROM scratch\n").unwrap();
518 let e = entry("typo", None, Some("yah-rsut")); 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}