| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312 |
- //! 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<Violation>) {
- // 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/<name>/recipe.toml` under `recipes_dir`.
- pub fn validate_dir(recipes_dir: &Path, images_dirs: &[PathBuf]) -> Vec<Violation> {
- let refs: Vec<&Path> = images_dirs.iter().map(|p| p.as_path()).collect();
- let mut out: Vec<Violation> = 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::<Recipe>(&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 (`<root>/docker-images`) plus `<cwd>/docker-images`.
- pub fn validate_all(config: &Config) -> Vec<Violation> {
- let mut image_roots: Vec<PathBuf> = 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<Violation> = 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::<Vec<_>>()
- .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:?}");
- }
- }
|