//! Recipe contract validation — `sm121 validate` + the `make check` gate //! (the repo-self test at the bottom of this file runs the validator over //! `recipes/` on every `cargo test`, so new recipes cannot break the //! contract silently). //! //! Rules (errors for active recipes; `archived = true` lanes are //! grandfathered — their errors downgrade to warnings): //! //! image-dir-missing `docker_image_dir` must exist under `docker-images/` //! port-convention `port` must be the cluster default 30000 //! floating-tag `docker_image` must not use a floating `:latest` //! (engine images; `alpine` test images are exempt) //! pinned-revision `model` must float (`org/name`), no `@revision` //! node-count `required_nodes` within 1..=8 (fleet size) //! //! Warnings (always advisory): //! //! parse recipe.toml failed to parse //! no-parent-model `model` set without `parent_model` (report tree) //! multi-node-tp1 `required_nodes > 1` with TP=1/PP=1 (replicated) use std::path::{Path, PathBuf}; use crate::config::{Config, DeployTarget, Recipe, recipe_dirs}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Level { Error, Warning, } #[derive(Debug)] pub struct Violation { pub recipe: String, pub level: Level, pub rule: &'static str, pub message: String, } /// The largest launch set is the 8-node switched cluster. const MAX_NODES: u32 = 8; fn check_recipe(name: &str, r: &Recipe, images_dirs: &[&Path], out: &mut Vec) { // Build context must exist in at least one image source (a recipe // promising an on-demand build from a deleted tree is a dead lane). if let Some(dir) = &r.docker_image_dir && !images_dirs.iter().any(|d| d.join(dir).is_dir()) { out.push(Violation { recipe: name.to_string(), level: Level::Error, rule: "image-dir-missing", message: format!("docker_image_dir '{dir}' not found under docker-images/"), }); } // Port convention: every recipe serves on the cluster default port. // Engines with a fixed, non-configurable API port (gr8 :8100, DGPP // :18080 — drivers that don't take --host/--port) are exempt. if r.port != 30000 && crate::engine::driver_for(&r.engine).takes_host_port() { out.push(Violation { recipe: name.to_string(), level: Level::Error, rule: "port-convention", message: format!("port {} — the cluster default is 30000", r.port), }); } // No floating engine tags (reproducibility; pin a digest). if r.docker_image.ends_with(":latest") && !r.docker_image.starts_with("alpine") { out.push(Violation { recipe: name.to_string(), level: Level::Error, rule: "floating-tag", message: format!( "docker_image '{}' uses a floating :latest tag — pin a digest", r.docker_image ), }); } // Models float; a revision goes in a comment + the bench manifest. if r.model.contains('@') && !r.model.starts_with('/') { out.push(Violation { recipe: name.to_string(), level: Level::Error, rule: "pinned-revision", message: format!( "model '{}' pins a revision — float it (org/name), record the rev in a comment", r.model ), }); } if let Some(n) = r.required_nodes && (n == 0 || n > MAX_NODES) { out.push(Violation { recipe: name.to_string(), level: Level::Error, rule: "node-count", message: format!("required_nodes {n} outside 1..={MAX_NODES}"), }); } // Advisory (both active and archived): if !r.model.is_empty() && !r.model.starts_with('/') && r.parent_model.is_none() { out.push(Violation { recipe: name.to_string(), level: Level::Warning, rule: "no-parent-model", message: "model set without parent_model (report tree grouping)".to_string(), }); } let multi = r.required_nodes.is_some_and(|n| n > 1); // Native (head-coordinated, config-driven) engines like DGPP shard by their // own `world_size`, not launcher TP/PP flags — the TP=1 heuristic is a // false positive there. if multi && r.deploy != DeployTarget::Native && r.tensor_parallel_size == 1 && r.pipeline_parallel_size.unwrap_or(1) == 1 { out.push(Violation { recipe: name.to_string(), level: Level::Warning, rule: "multi-node-tp1", message: "required_nodes > 1 with TP=1/PP=1 (model replicated across nodes)" .to_string(), }); } } /// Validate every `recipes//recipe.toml` under `recipes_dir`. pub fn validate_dir(recipes_dir: &Path, images_dirs: &[PathBuf]) -> Vec { let refs: Vec<&Path> = images_dirs.iter().map(|p| p.as_path()).collect(); let mut out: Vec = Vec::new(); let Ok(entries) = std::fs::read_dir(recipes_dir) else { return out; }; for entry in entries.flatten() { let path = entry.path(); if !path.is_dir() { continue; } let Some(name) = entry.file_name().to_str().map(String::from) else { continue; }; let toml_path = path.join("recipe.toml"); let Ok(toml_str) = std::fs::read_to_string(&toml_path) else { continue; }; let r = match toml::from_str::(&toml_str) { Ok(r) => r, Err(e) => { out.push(Violation { recipe: name, level: Level::Error, rule: "parse", message: format!("recipe.toml: {e}"), }); continue; } }; let mut v = Vec::new(); check_recipe(&name, &r, &refs, &mut v); if r.archived { for x in v.iter_mut() { if x.level == Level::Error { x.level = Level::Warning; } } } out.extend(v); } out } /// Validate every recipe source from the config (`./recipes` + /// `recipe_sources`). Build dirs are checked against every configured /// `docker_image_sources` root (`/docker-images`) plus `/docker-images`. pub fn validate_all(config: &Config) -> Vec { let mut image_roots: Vec = config .docker_image_sources .iter() .map(|s| PathBuf::from(s).join("docker-images")) .filter(|p| p.is_dir()) .collect(); if let Ok(cwd) = std::env::current_dir() { let local = cwd.join("docker-images"); if local.is_dir() && !image_roots.iter().any(|p| p == &local) { image_roots.push(local); } } let mut out: Vec = Vec::new(); for dir in recipe_dirs(config) { out.extend(validate_dir(&dir, &image_roots)); } out } #[cfg(test)] mod tests { use super::*; /// The make-check gate: the ACTIVE recipes of the working set must pass. /// The working set is `SM121_RECIPE_GATE_DIR` (a recipe-repo checkout — /// the Makefile points it at `../recipes`); without it, a local /// `recipes/` dir; if neither exists the gate is skipped (CI). /// Archived lanes are grandfathered (warnings only). #[test] fn repo_recipes_pass_the_gate() { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); let (recipes, images) = match std::env::var("SM121_RECIPE_GATE_DIR") { Ok(d) => { let p = std::path::Path::new(&d); (p.join("recipes"), p.join("docker-images")) } Err(_) => { if !root.join("recipes").exists() { eprintln!( "recipe gate skipped: no local recipes/ and no SM121_RECIPE_GATE_DIR" ); return; } (root.join("recipes"), root.join("docker-images")) } }; let v = validate_dir(&recipes, &[images]); let errs: Vec<&Violation> = v.iter().filter(|x| x.level == Level::Error).collect(); assert!( errs.is_empty(), "recipe gate failed:\n{}", errs.iter() .map(|e| format!(" {} [{}]: {}", e.recipe, e.rule, e.message)) .collect::>() .join("\n") ); } #[test] fn rules_fire_on_bad_recipe() { let toml = r#" name = "bad" docker_image = "eugr/x:latest" docker_image_dir = "no-such-dir" model = "org/m@deadbeef" port = 8000 required_nodes = 9 "#; let r: Recipe = toml::from_str(toml).expect("test recipe parses"); let mut out = Vec::new(); check_recipe( "bad", &r, &[Path::new("/nonexistent/docker-images")], &mut out, ); let rules: Vec<&str> = out.iter().map(|v| v.rule).collect(); for want in [ "floating-tag", "image-dir-missing", "pinned-revision", "port-convention", "node-count", "no-parent-model", "multi-node-tp1", ] { assert!(rules.contains(&want), "missing rule {want}; got {rules:?}"); } } #[test] fn good_recipe_is_clean() { let toml = r#" name = "ok" docker_image = "img:tag" model = "org/m" parent_model = "org/base" required_nodes = 2 tensor_parallel_size = 2 "#; let r: Recipe = toml::from_str(toml).expect("test recipe parses"); let mut out = Vec::new(); check_recipe( "ok", &r, &[Path::new("/nonexistent/docker-images")], &mut out, ); assert!(out.is_empty(), "unexpected violations: {out:?}"); } #[test] fn archived_lane_downgrades_errors() { let toml = r#" name = "old" archived = true docker_image = "eugr/x:latest" "#; let r: Recipe = toml::from_str(toml).expect("test recipe parses"); let mut out = Vec::new(); check_recipe( "old", &r, &[Path::new("/nonexistent/docker-images")], &mut out, ); // simulate the archived downgrade from validate_dir for x in out.iter_mut() { if x.level == Level::Error { x.level = Level::Warning; } } assert!(out.iter().all(|v| v.level == Level::Warning), "{out:?}"); } }