use std::collections::HashSet;
use std::path::Path;
use thiserror::Error;
use super::catalog::{CatalogEntry, CatalogManifest};
pub const MAX_EXTENDS_DEPTH: usize = 5;
#[derive(Error, Debug)]
pub enum CompileError {
#[error("catalog entry `{name}` extends unknown image `{target}`")]
ExtendsNotFound { name: String, target: String },
#[error("catalog entry `{name}` has a cyclic extends chain: {}", chain.join(" → "))]
ExtendsCycle { name: String, chain: Vec<String> },
#[error("catalog entry `{name}` extends chain is deeper than the {max}-step limit")]
ExtendsTooDeep { name: String, max: usize },
#[error("catalog entry `{0}` has neither `base` nor `extends`")]
NoBase(String),
#[error("io error reading sibling Dockerfile at {path}: {source}")]
DockerfileIo {
path: String,
source: std::io::Error,
},
}
pub fn catalog_image_ref(entry_name: &str) -> String {
format!("ghcr.io/yah-ai/{entry_name}:latest")
}
pub const IMAGE_DIR_SEARCH_PATH: &[&str] = &[
".yah/qed/images",
"oss/qed/crates/qed/images",
"crates/qed/images",
];
pub fn resolve_image_dir(camp_root: &Path, entry_name: &str) -> Option<std::path::PathBuf> {
IMAGE_DIR_SEARCH_PATH
.iter()
.map(|rel| Path::new(rel).join(entry_name))
.find(|rel| camp_root.join(rel).join("Dockerfile").is_file())
}
pub fn compile_entry(
entry: &CatalogEntry,
catalog: &CatalogManifest,
) -> Result<String, CompileError> {
validate_extends_chain(entry, catalog)?;
Ok(emit_dockerfile(entry))
}
pub fn compile_with_dockerfile_dir(
entry: &CatalogEntry,
catalog: &CatalogManifest,
dir: &Path,
) -> Result<String, CompileError> {
let dockerfile_path = dir.join("Dockerfile");
if !dockerfile_path.is_file() {
return compile_entry(entry, catalog);
}
validate_extends_chain(entry, catalog)?;
let contents =
std::fs::read_to_string(&dockerfile_path).map_err(|e| CompileError::DockerfileIo {
path: dockerfile_path.display().to_string(),
source: e,
})?;
if has_from_line(&contents) {
return Ok(contents);
}
if let Some(from_line) = from_line_for(entry) {
Ok(format!("{from_line}\n\n{contents}"))
} else {
Ok(contents)
}
}
fn validate_extends_chain(
entry: &CatalogEntry,
catalog: &CatalogManifest,
) -> Result<(), CompileError> {
let mut chain = vec![entry.name.clone()];
let mut seen: HashSet<String> = HashSet::new();
seen.insert(entry.name.clone());
let mut cursor: &CatalogEntry = entry;
while let Some(parent_name) = cursor.extends.as_deref() {
if !seen.insert(parent_name.to_string()) {
chain.push(parent_name.to_string());
return Err(CompileError::ExtendsCycle {
name: entry.name.clone(),
chain,
});
}
chain.push(parent_name.to_string());
if chain.len() > MAX_EXTENDS_DEPTH {
return Err(CompileError::ExtendsTooDeep {
name: entry.name.clone(),
max: MAX_EXTENDS_DEPTH,
});
}
cursor = catalog
.get(parent_name)
.ok_or_else(|| CompileError::ExtendsNotFound {
name: entry.name.clone(),
target: parent_name.to_string(),
})?;
}
if cursor.base.is_none() {
return Err(CompileError::NoBase(cursor.name.clone()));
}
Ok(())
}
fn from_line_for(entry: &CatalogEntry) -> Option<String> {
if let Some(parent) = &entry.extends {
return Some(format!("FROM {}", catalog_image_ref(parent)));
}
entry.base.as_ref().map(|b| format!("FROM {b}"))
}
fn has_from_line(dockerfile: &str) -> bool {
dockerfile.lines().any(|l| {
let trimmed = l.trim_start();
if trimmed.starts_with('#') || trimmed.is_empty() {
return false;
}
trimmed
.split_whitespace()
.next()
.map(|w| w.eq_ignore_ascii_case("FROM"))
.unwrap_or(false)
})
}
fn emit_dockerfile(entry: &CatalogEntry) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push("# syntax=docker/dockerfile:1".to_string());
lines.push(format!(
"# Generated by qed::images::compile for catalog entry `{}`.",
entry.name
));
if let Some(from) = from_line_for(entry) {
lines.push(from);
}
if !entry.apt.is_empty() {
let mut pkgs = entry.apt.clone();
pkgs.sort();
lines.push(format!(
"RUN apt-get update \\\n && apt-get install -y --no-install-recommends \\\n {} \\\n && rm -rf /var/lib/apt/lists/*",
pkgs.join(" \\\n ")
));
}
if !entry.pip.is_empty() {
let mut pkgs = entry.pip.clone();
pkgs.sort();
lines.push(format!(
"RUN pip install --no-cache-dir \\\n {}",
pkgs.join(" \\\n ")
));
}
if !entry.env.is_empty() {
let mut pairs: Vec<(&String, &String)> = entry.env.iter().collect();
pairs.sort_by(|a, b| a.0.cmp(b.0));
for (k, v) in pairs {
lines.push(format!("ENV {k}={v}"));
}
}
let mut out = lines.join("\n");
out.push('\n');
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::fs;
use tempfile::tempdir;
fn entry(name: &str, base: Option<&str>, extends: Option<&str>) -> CatalogEntry {
CatalogEntry {
name: name.into(),
base: base.map(Into::into),
extends: extends.map(Into::into),
description: format!("{name} test fixture"),
tools: Vec::new(),
digests: HashMap::new(),
apt: Vec::new(),
pip: Vec::new(),
env: HashMap::new(),
produces: vec![crate::images::ProduceTarget::OciImage],
}
}
fn bundled() -> CatalogManifest {
CatalogManifest::bundled().unwrap()
}
#[test]
fn pure_toml_layering_yah_rust_pg() {
let mut e = entry("yah-rust-pg", None, Some("yah-rust"));
e.apt = vec!["postgresql-client".into(), "libpq-dev".into()];
e.env = HashMap::from([("PGUSER".into(), "yah".into())]);
let dockerfile = compile_entry(&e, &bundled()).unwrap();
assert!(
dockerfile.contains("FROM ghcr.io/yah-ai/yah-rust:latest"),
"missing FROM: {dockerfile}"
);
assert!(
dockerfile.contains("apt-get install"),
"missing apt: {dockerfile}"
);
assert!(
dockerfile.contains("libpq-dev"),
"missing package: {dockerfile}"
);
assert!(
dockerfile.contains("postgresql-client"),
"missing package: {dockerfile}"
);
assert!(
dockerfile.contains("ENV PGUSER=yah"),
"missing env: {dockerfile}"
);
}
#[test]
fn pure_toml_pip_layering() {
let mut e = entry("ml-runner", None, Some("yah-python"));
e.pip = vec!["numpy".into(), "scipy".into(), "pandas".into()];
let dockerfile = compile_entry(&e, &bundled()).unwrap();
assert!(dockerfile.contains("FROM ghcr.io/yah-ai/yah-python:latest"));
assert!(dockerfile.contains("pip install --no-cache-dir"));
assert!(dockerfile.contains("numpy"));
assert!(dockerfile.contains("pandas"));
}
#[test]
fn base_only_entry_emits_from_base() {
let e = entry("custom-base", Some("alpine:3.20"), None);
let dockerfile = compile_entry(&e, &bundled()).unwrap();
assert!(dockerfile.contains("FROM alpine:3.20"));
}
#[test]
fn extends_chain_validates_root_has_base() {
let e = bundled().get("yah-rust-bun").unwrap().clone();
compile_entry(&e, &bundled()).unwrap();
}
#[test]
fn extends_unknown_target_rejected() {
let e = entry("orphan", None, Some("does-not-exist"));
let err = compile_entry(&e, &bundled()).unwrap_err();
assert!(
matches!(err, CompileError::ExtendsNotFound { ref target, .. } if target == "does-not-exist")
);
}
#[test]
fn extends_cycle_detected() {
let a = {
let mut e = entry("a", None, Some("b"));
e.description = "cycle a".into();
e
};
let b = entry("b", None, Some("a"));
let dir = tempdir().unwrap();
fs::write(
dir.path().join("a.toml"),
r#"
[image]
name = "a"
extends = "b"
description = "cycle a"
"#,
)
.unwrap();
fs::write(
dir.path().join("b.toml"),
r#"
[image]
name = "b"
extends = "a"
description = "cycle b"
"#,
)
.unwrap();
let manifest = CatalogManifest::load(dir.path()).unwrap();
let err = compile_entry(&a, &manifest).unwrap_err();
match err {
CompileError::ExtendsCycle { name, chain } => {
assert_eq!(name, "a");
assert_eq!(chain.first().map(String::as_str), Some("a"));
assert!(
chain.iter().filter(|n| *n == "a").count() >= 2,
"cycle chain shows return: {chain:?}"
);
}
other => panic!("expected ExtendsCycle, got {other:?}"),
}
assert_eq!(b.name, "b");
}
#[test]
fn extends_depth_limit_enforced() {
let dir = tempdir().unwrap();
for (name, parent) in [
("a", "b"),
("b", "c"),
("c", "d"),
("d", "e"),
("e", "f"),
("f", "g"),
] {
fs::write(
dir.path().join(format!("{name}.toml")),
format!(
r#"
[image]
name = "{name}"
extends = "{parent}"
description = "depth fixture"
"#
),
)
.unwrap();
}
fs::write(
dir.path().join("g.toml"),
r#"
[image]
name = "g"
base = "alpine:3.20"
description = "root"
"#,
)
.unwrap();
let manifest = CatalogManifest::load(dir.path()).unwrap();
let a = manifest.get("a").unwrap().clone();
let err = compile_entry(&a, &manifest).unwrap_err();
assert!(
matches!(err, CompileError::ExtendsTooDeep { ref name, max } if name == "a" && max == MAX_EXTENDS_DEPTH),
"got: {err:?}"
);
}
#[test]
fn sibling_dockerfile_returned_verbatim_when_from_present() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("Dockerfile"),
"FROM debian:bookworm-slim\nRUN echo hi\n",
)
.unwrap();
let e = entry("custom", None, Some("yah-base"));
let out = compile_with_dockerfile_dir(&e, &bundled(), dir.path()).unwrap();
assert_eq!(out, "FROM debian:bookworm-slim\nRUN echo hi\n");
}
#[test]
fn sibling_dockerfile_gets_from_prefix_when_missing() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("Dockerfile"), "RUN echo hi\n").unwrap();
let e = entry("custom", None, Some("yah-base"));
let out = compile_with_dockerfile_dir(&e, &bundled(), dir.path()).unwrap();
assert!(
out.starts_with("FROM ghcr.io/yah-ai/yah-base:latest"),
"missing prepended FROM: {out}"
);
assert!(out.contains("RUN echo hi"));
}
#[test]
fn sibling_dockerfile_with_comment_only_still_gets_prefix() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("Dockerfile"),
"# syntax=docker/dockerfile:1\nRUN echo hi\n",
)
.unwrap();
let e = entry("custom", None, Some("yah-base"));
let out = compile_with_dockerfile_dir(&e, &bundled(), dir.path()).unwrap();
assert!(
out.starts_with("FROM ghcr.io/yah-ai/yah-base:latest"),
"syntax directive shouldn't count as FROM: {out}"
);
}
#[test]
fn dir_without_dockerfile_falls_back_to_toml_layering() {
let dir = tempdir().unwrap();
let mut e = entry("layered", None, Some("yah-base"));
e.apt = vec!["jq".into()];
let out = compile_with_dockerfile_dir(&e, &bundled(), dir.path()).unwrap();
assert!(out.contains("FROM ghcr.io/yah-ai/yah-base:latest"));
assert!(out.contains("jq"));
}
#[test]
fn sibling_dockerfile_with_invalid_extends_still_rejected() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("Dockerfile"), "FROM scratch\n").unwrap();
let e = entry("typo", None, Some("yah-rsut")); let err = compile_with_dockerfile_dir(&e, &bundled(), dir.path()).unwrap_err();
assert!(
matches!(err, CompileError::ExtendsNotFound { ref target, .. } if target == "yah-rsut")
);
}
}