|
|
@@ -0,0 +1,259 @@
|
|
|
+//! 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;
|
|
|
+
|
|
|
+use crate::config::{Config, 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_dir: &Path, out: &mut Vec<Violation>) {
|
|
|
+ // Build context must exist (a recipe promising an on-demand build from
|
|
|
+ // a deleted tree is a dead lane).
|
|
|
+ if let Some(dir) = &r.docker_image_dir
|
|
|
+ && !images_dir.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.
|
|
|
+ if r.port != 30000 {
|
|
|
+ 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);
|
|
|
+ if multi && 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_dir: &Path) -> Vec<Violation> {
|
|
|
+ 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, images_dir, &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 `<cwd>/docker-images`.
|
|
|
+pub fn validate_all(config: &Config) -> Vec<Violation> {
|
|
|
+ let images_dir = std::env::current_dir()
|
|
|
+ .map(|cwd| cwd.join("docker-images"))
|
|
|
+ .unwrap_or_default();
|
|
|
+ let mut out: Vec<Violation> = Vec::new();
|
|
|
+ for dir in recipe_dirs(config) {
|
|
|
+ out.extend(validate_dir(&dir, &images_dir));
|
|
|
+ }
|
|
|
+ out
|
|
|
+}
|
|
|
+
|
|
|
+#[cfg(test)]
|
|
|
+mod tests {
|
|
|
+ use super::*;
|
|
|
+
|
|
|
+ /// The make-check gate: the repo's own ACTIVE recipes must pass.
|
|
|
+ /// Archived lanes are grandfathered (warnings only).
|
|
|
+ #[test]
|
|
|
+ fn repo_recipes_pass_the_gate() {
|
|
|
+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
|
+ let v = validate_dir(&root.join("recipes"), &root.join("docker-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:?}");
|
|
|
+ }
|
|
|
+}
|