use serde::Deserialize;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProduceTarget {
OciImage,
NativeTarball,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct CatalogEntry {
pub name: String,
#[serde(default)]
pub base: Option<String>,
#[serde(default)]
pub extends: Option<String>,
pub description: String,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub digests: HashMap<String, String>,
#[serde(default)]
pub apt: Vec<String>,
#[serde(default)]
pub pip: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default = "default_produces")]
pub produces: Vec<ProduceTarget>,
}
fn default_produces() -> Vec<ProduceTarget> {
vec![ProduceTarget::OciImage]
}
impl CatalogEntry {
fn validate(&self) -> Result<(), CatalogError> {
match (self.base.as_deref(), self.extends.as_deref()) {
(None, None) => return Err(CatalogError::OrphanEntry(self.name.clone())),
(Some(_), Some(_)) => return Err(CatalogError::AmbiguousBase(self.name.clone())),
_ => {}
}
if self.produces.is_empty() {
return Err(CatalogError::EmptyProduces(self.name.clone()));
}
Ok(())
}
}
#[derive(Error, Debug)]
pub enum CatalogError {
#[error("IO error reading catalog: {0}")]
Io(#[from] std::io::Error),
#[error("TOML parse error in {path}: {source}")]
TomlParse {
path: String,
source: toml::de::Error,
},
#[error("catalog entry `{0}` has neither `base` nor `extends`")]
OrphanEntry(String),
#[error("catalog entry `{0}` sets both `base` and `extends` (pick one)")]
AmbiguousBase(String),
#[error("catalog entry `{0}` declares an empty `produces` list (must yield at least one of `oci-image`, `native-tarball`)")]
EmptyProduces(String),
}
#[derive(Debug, Deserialize)]
struct BundledToml {
#[serde(default)]
image: Vec<CatalogEntry>,
}
#[derive(Debug, Deserialize)]
struct PerCampToml {
image: CatalogEntry,
}
const BUNDLED_CATALOG_TOML: &str = include_str!("../../images/catalog.toml");
#[derive(Debug, Clone)]
pub struct CatalogManifest {
entries: Vec<CatalogEntry>,
}
impl CatalogManifest {
pub fn bundled() -> Result<Self, CatalogError> {
Self::from_bundled_str(BUNDLED_CATALOG_TOML)
}
pub fn load(qed_images_dir: impl AsRef<Path>) -> Result<Self, CatalogError> {
let mut manifest = Self::bundled()?;
let dir = qed_images_dir.as_ref();
if !dir.is_dir() {
return Ok(manifest);
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
let parsed = if file_type.is_file() && path.extension().map_or(false, |e| e == "toml") {
Some(parse_per_camp(&path)?)
} else if file_type.is_dir() {
let nested = path.join("image.toml");
if nested.is_file() {
Some(parse_per_camp(&nested)?)
} else {
None
}
} else {
None
};
if let Some(entry) = parsed {
manifest.upsert(entry);
}
}
Ok(manifest)
}
fn from_bundled_str(src: &str) -> Result<Self, CatalogError> {
let parsed: BundledToml = toml::from_str(src).map_err(|e| CatalogError::TomlParse {
path: "<bundled catalog.toml>".to_string(),
source: e,
})?;
for entry in &parsed.image {
entry.validate()?;
}
Ok(Self {
entries: parsed.image,
})
}
fn upsert(&mut self, entry: CatalogEntry) {
if let Some(slot) = self.entries.iter_mut().find(|e| e.name == entry.name) {
*slot = entry;
} else {
self.entries.push(entry);
}
}
pub fn entries(&self) -> &[CatalogEntry] {
&self.entries
}
pub fn get(&self, name: &str) -> Option<&CatalogEntry> {
self.entries.iter().find(|e| e.name == name)
}
pub fn names(&self) -> Vec<&str> {
self.entries.iter().map(|e| e.name.as_str()).collect()
}
}
fn parse_per_camp(path: &Path) -> Result<CatalogEntry, CatalogError> {
let content = fs::read_to_string(path)?;
let parsed: PerCampToml = toml::from_str(&content).map_err(|e| CatalogError::TomlParse {
path: path.display().to_string(),
source: e,
})?;
parsed.image.validate()?;
Ok(parsed.image)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
const EXPECTED_BUNDLED: &[&str] = &[
"yah-base",
"yah-rust",
"yah-bun",
"yah-rust-bun",
"yah-python",
"yah-cuda",
"yah-node",
"yah-yubaba",
"yah-miniflare",
"yah-cloud-runner",
"rusty-v8-musl-builder",
];
#[test]
fn bundled_catalog_loads_with_all_entries() {
let manifest = CatalogManifest::bundled().expect("bundled catalog parses");
let names = manifest.names();
for expected in EXPECTED_BUNDLED {
assert!(
names.contains(expected),
"bundled catalog missing `{expected}` (got {names:?})"
);
}
}
#[test]
fn yah_rust_bun_extends_yah_rust() {
let manifest = CatalogManifest::bundled().unwrap();
let entry = manifest.get("yah-rust-bun").expect("yah-rust-bun present");
assert_eq!(entry.extends.as_deref(), Some("yah-rust"));
assert!(entry.base.is_none());
}
#[test]
fn primitive_entry_has_base_not_extends() {
let manifest = CatalogManifest::bundled().unwrap();
let entry = manifest.get("yah-rust").expect("yah-rust present");
assert!(entry.extends.is_none());
assert_eq!(entry.base.as_deref(), Some("rust:1-slim-bookworm"));
}
#[test]
fn bundled_entries_have_empty_digests() {
let manifest = CatalogManifest::bundled().unwrap();
for entry in manifest.entries() {
assert!(
entry.digests.is_empty(),
"bundled entry `{}` carries digests in source (should be release-time inject only)",
entry.name,
);
}
}
#[test]
fn missing_camp_dir_returns_bundled() {
let manifest = CatalogManifest::load("/nonexistent/path/does/not/exist").unwrap();
assert_eq!(manifest.names().len(), EXPECTED_BUNDLED.len());
}
#[test]
fn per_camp_flat_toml_overrides_by_name() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("yah-rust.toml"),
r#"
[image]
name = "yah-rust"
base = "rust:1.80-slim-bookworm"
description = "Pinned to 1.80 for this camp"
tools = ["cargo", "rustc"]
"#,
)
.unwrap();
let manifest = CatalogManifest::load(dir.path()).unwrap();
let entry = manifest.get("yah-rust").unwrap();
assert_eq!(entry.base.as_deref(), Some("rust:1.80-slim-bookworm"));
assert_eq!(entry.description, "Pinned to 1.80 for this camp");
}
#[test]
fn per_camp_directory_form_overrides_by_name() {
let dir = tempdir().unwrap();
let sub = dir.path().join("yah-python");
fs::create_dir(&sub).unwrap();
fs::write(
sub.join("image.toml"),
r#"
[image]
name = "yah-python"
base = "python:3.12-slim-bookworm"
description = "Pinned to 3.12"
tools = ["pip"]
"#,
)
.unwrap();
let manifest = CatalogManifest::load(dir.path()).unwrap();
let entry = manifest.get("yah-python").unwrap();
assert_eq!(entry.base.as_deref(), Some("python:3.12-slim-bookworm"));
}
#[test]
fn per_camp_new_entry_appended() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("yah-rust-pg.toml"),
r#"
[image]
name = "yah-rust-pg"
extends = "yah-rust"
description = "Rust + postgres client"
tools = ["psql"]
"#,
)
.unwrap();
let manifest = CatalogManifest::load(dir.path()).unwrap();
assert!(manifest.get("yah-rust-pg").is_some());
assert_eq!(manifest.names().len(), EXPECTED_BUNDLED.len() + 1);
}
#[test]
fn orphan_entry_rejected() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("broken.toml"),
r#"
[image]
name = "broken"
description = "No base or extends"
"#,
)
.unwrap();
let err = CatalogManifest::load(dir.path()).unwrap_err();
assert!(matches!(err, CatalogError::OrphanEntry(ref n) if n == "broken"));
}
#[test]
fn ambiguous_base_rejected() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("both.toml"),
r#"
[image]
name = "both"
base = "debian:bookworm-slim"
extends = "yah-base"
description = "Sets both — ambiguous"
"#,
)
.unwrap();
let err = CatalogManifest::load(dir.path()).unwrap_err();
assert!(matches!(err, CatalogError::AmbiguousBase(ref n) if n == "both"));
}
#[test]
fn bundled_entries_default_to_oci_image_produces() {
let manifest = CatalogManifest::bundled().unwrap();
for entry in manifest.entries() {
assert_eq!(
entry.produces,
vec![ProduceTarget::OciImage],
"bundled entry `{}` should default to [oci-image] (no explicit produces in source)",
entry.name,
);
}
}
#[test]
fn per_camp_produces_native_tarball_parses() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("yah-yubaba.toml"),
r#"
[image]
name = "yah-yubaba"
base = "scratch"
description = "Native musl-static yubaba binary"
produces = ["native-tarball"]
"#,
)
.unwrap();
let manifest = CatalogManifest::load(dir.path()).unwrap();
let entry = manifest.get("yah-yubaba").unwrap();
assert_eq!(entry.produces, vec![ProduceTarget::NativeTarball]);
}
#[test]
fn per_camp_produces_both_targets_parses() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("yah-yubaba.toml"),
r#"
[image]
name = "yah-yubaba"
base = "debian:bookworm-slim"
description = "Container + native peer"
produces = ["oci-image", "native-tarball"]
"#,
)
.unwrap();
let manifest = CatalogManifest::load(dir.path()).unwrap();
let entry = manifest.get("yah-yubaba").unwrap();
assert_eq!(
entry.produces,
vec![ProduceTarget::OciImage, ProduceTarget::NativeTarball],
);
}
#[test]
fn empty_produces_rejected() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("ghost.toml"),
r#"
[image]
name = "ghost"
base = "scratch"
description = "Produces nothing — must reject"
produces = []
"#,
)
.unwrap();
let err = CatalogManifest::load(dir.path()).unwrap_err();
assert!(matches!(err, CatalogError::EmptyProduces(ref n) if n == "ghost"));
}
#[test]
fn unknown_produces_variant_rejected() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("bad.toml"),
r#"
[image]
name = "bad"
base = "scratch"
description = "Bogus produces variant"
produces = ["systemd-portable"]
"#,
)
.unwrap();
let err = CatalogManifest::load(dir.path()).unwrap_err();
assert!(matches!(err, CatalogError::TomlParse { .. }));
}
#[test]
fn unrelated_files_ignored() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("README.md"), "ignore me").unwrap();
fs::create_dir(dir.path().join("empty-dir")).unwrap();
let manifest = CatalogManifest::load(dir.path()).unwrap();
assert_eq!(manifest.names().len(), EXPECTED_BUNDLED.len());
}
}