validate.rs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. //! Recipe contract validation — `sm121 validate` + the `make check` gate
  2. //! (the repo-self test at the bottom of this file runs the validator over
  3. //! `recipes/` on every `cargo test`, so new recipes cannot break the
  4. //! contract silently).
  5. //!
  6. //! Rules (errors for active recipes; `archived = true` lanes are
  7. //! grandfathered — their errors downgrade to warnings):
  8. //!
  9. //! image-dir-missing `docker_image_dir` must exist under `docker-images/`
  10. //! port-convention `port` must be the cluster default 30000
  11. //! floating-tag `docker_image` must not use a floating `:latest`
  12. //! (engine images; `alpine` test images are exempt)
  13. //! pinned-revision `model` must float (`org/name`), no `@revision`
  14. //! node-count `required_nodes` within 1..=8 (fleet size)
  15. //!
  16. //! Warnings (always advisory):
  17. //!
  18. //! parse recipe.toml failed to parse
  19. //! no-parent-model `model` set without `parent_model` (report tree)
  20. //! multi-node-tp1 `required_nodes > 1` with TP=1/PP=1 (replicated)
  21. use std::path::{Path, PathBuf};
  22. use crate::config::{Config, DeployTarget, Recipe, recipe_dirs};
  23. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  24. pub enum Level {
  25. Error,
  26. Warning,
  27. }
  28. #[derive(Debug)]
  29. pub struct Violation {
  30. pub recipe: String,
  31. pub level: Level,
  32. pub rule: &'static str,
  33. pub message: String,
  34. }
  35. /// The largest launch set is the 8-node switched cluster.
  36. const MAX_NODES: u32 = 8;
  37. fn check_recipe(name: &str, r: &Recipe, images_dirs: &[&Path], out: &mut Vec<Violation>) {
  38. // Build context must exist in at least one image source (a recipe
  39. // promising an on-demand build from a deleted tree is a dead lane).
  40. if let Some(dir) = &r.docker_image_dir
  41. && !images_dirs.iter().any(|d| d.join(dir).is_dir())
  42. {
  43. out.push(Violation {
  44. recipe: name.to_string(),
  45. level: Level::Error,
  46. rule: "image-dir-missing",
  47. message: format!("docker_image_dir '{dir}' not found under docker-images/"),
  48. });
  49. }
  50. // Port convention: every recipe serves on the cluster default port.
  51. // Engines with a fixed, non-configurable API port (gr8 :8100, DGPP
  52. // :18080 — drivers that don't take --host/--port) are exempt.
  53. if r.port != 30000 && crate::engine::driver_for(&r.engine).takes_host_port() {
  54. out.push(Violation {
  55. recipe: name.to_string(),
  56. level: Level::Error,
  57. rule: "port-convention",
  58. message: format!("port {} — the cluster default is 30000", r.port),
  59. });
  60. }
  61. // No floating engine tags (reproducibility; pin a digest).
  62. if r.docker_image.ends_with(":latest") && !r.docker_image.starts_with("alpine") {
  63. out.push(Violation {
  64. recipe: name.to_string(),
  65. level: Level::Error,
  66. rule: "floating-tag",
  67. message: format!(
  68. "docker_image '{}' uses a floating :latest tag — pin a digest",
  69. r.docker_image
  70. ),
  71. });
  72. }
  73. // Models float; a revision goes in a comment + the bench manifest.
  74. if r.model.contains('@') && !r.model.starts_with('/') {
  75. out.push(Violation {
  76. recipe: name.to_string(),
  77. level: Level::Error,
  78. rule: "pinned-revision",
  79. message: format!(
  80. "model '{}' pins a revision — float it (org/name), record the rev in a comment",
  81. r.model
  82. ),
  83. });
  84. }
  85. if let Some(n) = r.required_nodes
  86. && (n == 0 || n > MAX_NODES)
  87. {
  88. out.push(Violation {
  89. recipe: name.to_string(),
  90. level: Level::Error,
  91. rule: "node-count",
  92. message: format!("required_nodes {n} outside 1..={MAX_NODES}"),
  93. });
  94. }
  95. // Advisory (both active and archived):
  96. if !r.model.is_empty() && !r.model.starts_with('/') && r.parent_model.is_none() {
  97. out.push(Violation {
  98. recipe: name.to_string(),
  99. level: Level::Warning,
  100. rule: "no-parent-model",
  101. message: "model set without parent_model (report tree grouping)".to_string(),
  102. });
  103. }
  104. let multi = r.required_nodes.is_some_and(|n| n > 1);
  105. // Native (head-coordinated, config-driven) engines like DGPP shard by their
  106. // own `world_size`, not launcher TP/PP flags — the TP=1 heuristic is a
  107. // false positive there.
  108. if multi
  109. && r.deploy != DeployTarget::Native
  110. && r.tensor_parallel_size == 1
  111. && r.pipeline_parallel_size.unwrap_or(1) == 1
  112. {
  113. out.push(Violation {
  114. recipe: name.to_string(),
  115. level: Level::Warning,
  116. rule: "multi-node-tp1",
  117. message: "required_nodes > 1 with TP=1/PP=1 (model replicated across nodes)"
  118. .to_string(),
  119. });
  120. }
  121. }
  122. /// Validate every `recipes/<name>/recipe.toml` under `recipes_dir`.
  123. pub fn validate_dir(recipes_dir: &Path, images_dirs: &[PathBuf]) -> Vec<Violation> {
  124. let refs: Vec<&Path> = images_dirs.iter().map(|p| p.as_path()).collect();
  125. let mut out: Vec<Violation> = Vec::new();
  126. let Ok(entries) = std::fs::read_dir(recipes_dir) else {
  127. return out;
  128. };
  129. for entry in entries.flatten() {
  130. let path = entry.path();
  131. if !path.is_dir() {
  132. continue;
  133. }
  134. let Some(name) = entry.file_name().to_str().map(String::from) else {
  135. continue;
  136. };
  137. let toml_path = path.join("recipe.toml");
  138. let Ok(toml_str) = std::fs::read_to_string(&toml_path) else {
  139. continue;
  140. };
  141. let r = match toml::from_str::<Recipe>(&toml_str) {
  142. Ok(r) => r,
  143. Err(e) => {
  144. out.push(Violation {
  145. recipe: name,
  146. level: Level::Error,
  147. rule: "parse",
  148. message: format!("recipe.toml: {e}"),
  149. });
  150. continue;
  151. }
  152. };
  153. let mut v = Vec::new();
  154. check_recipe(&name, &r, &refs, &mut v);
  155. if r.archived {
  156. for x in v.iter_mut() {
  157. if x.level == Level::Error {
  158. x.level = Level::Warning;
  159. }
  160. }
  161. }
  162. out.extend(v);
  163. }
  164. out
  165. }
  166. /// Validate every recipe source from the config (`./recipes` +
  167. /// `recipe_sources`). Build dirs are checked against every configured
  168. /// `docker_image_sources` root (`<root>/docker-images`) plus `<cwd>/docker-images`.
  169. pub fn validate_all(config: &Config) -> Vec<Violation> {
  170. let mut image_roots: Vec<PathBuf> = config
  171. .docker_image_sources
  172. .iter()
  173. .map(|s| PathBuf::from(s).join("docker-images"))
  174. .filter(|p| p.is_dir())
  175. .collect();
  176. if let Ok(cwd) = std::env::current_dir() {
  177. let local = cwd.join("docker-images");
  178. if local.is_dir() && !image_roots.iter().any(|p| p == &local) {
  179. image_roots.push(local);
  180. }
  181. }
  182. let mut out: Vec<Violation> = Vec::new();
  183. for dir in recipe_dirs(config) {
  184. out.extend(validate_dir(&dir, &image_roots));
  185. }
  186. out
  187. }
  188. #[cfg(test)]
  189. mod tests {
  190. use super::*;
  191. /// The make-check gate: the ACTIVE recipes of the working set must pass.
  192. /// The working set is `SM121_RECIPE_GATE_DIR` (a recipe-repo checkout —
  193. /// the Makefile points it at `../recipes`); without it, a local
  194. /// `recipes/` dir; if neither exists the gate is skipped (CI).
  195. /// Archived lanes are grandfathered (warnings only).
  196. #[test]
  197. fn repo_recipes_pass_the_gate() {
  198. let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
  199. let (recipes, images) = match std::env::var("SM121_RECIPE_GATE_DIR") {
  200. Ok(d) => {
  201. let p = std::path::Path::new(&d);
  202. (p.join("recipes"), p.join("docker-images"))
  203. }
  204. Err(_) => {
  205. if !root.join("recipes").exists() {
  206. eprintln!(
  207. "recipe gate skipped: no local recipes/ and no SM121_RECIPE_GATE_DIR"
  208. );
  209. return;
  210. }
  211. (root.join("recipes"), root.join("docker-images"))
  212. }
  213. };
  214. let v = validate_dir(&recipes, &[images]);
  215. let errs: Vec<&Violation> = v.iter().filter(|x| x.level == Level::Error).collect();
  216. assert!(
  217. errs.is_empty(),
  218. "recipe gate failed:\n{}",
  219. errs.iter()
  220. .map(|e| format!(" {} [{}]: {}", e.recipe, e.rule, e.message))
  221. .collect::<Vec<_>>()
  222. .join("\n")
  223. );
  224. }
  225. #[test]
  226. fn rules_fire_on_bad_recipe() {
  227. let toml = r#"
  228. name = "bad"
  229. docker_image = "eugr/x:latest"
  230. docker_image_dir = "no-such-dir"
  231. model = "org/m@deadbeef"
  232. port = 8000
  233. required_nodes = 9
  234. "#;
  235. let r: Recipe = toml::from_str(toml).expect("test recipe parses");
  236. let mut out = Vec::new();
  237. check_recipe(
  238. "bad",
  239. &r,
  240. &[Path::new("/nonexistent/docker-images")],
  241. &mut out,
  242. );
  243. let rules: Vec<&str> = out.iter().map(|v| v.rule).collect();
  244. for want in [
  245. "floating-tag",
  246. "image-dir-missing",
  247. "pinned-revision",
  248. "port-convention",
  249. "node-count",
  250. "no-parent-model",
  251. "multi-node-tp1",
  252. ] {
  253. assert!(rules.contains(&want), "missing rule {want}; got {rules:?}");
  254. }
  255. }
  256. #[test]
  257. fn good_recipe_is_clean() {
  258. let toml = r#"
  259. name = "ok"
  260. docker_image = "img:tag"
  261. model = "org/m"
  262. parent_model = "org/base"
  263. required_nodes = 2
  264. tensor_parallel_size = 2
  265. "#;
  266. let r: Recipe = toml::from_str(toml).expect("test recipe parses");
  267. let mut out = Vec::new();
  268. check_recipe(
  269. "ok",
  270. &r,
  271. &[Path::new("/nonexistent/docker-images")],
  272. &mut out,
  273. );
  274. assert!(out.is_empty(), "unexpected violations: {out:?}");
  275. }
  276. #[test]
  277. fn archived_lane_downgrades_errors() {
  278. let toml = r#"
  279. name = "old"
  280. archived = true
  281. docker_image = "eugr/x:latest"
  282. "#;
  283. let r: Recipe = toml::from_str(toml).expect("test recipe parses");
  284. let mut out = Vec::new();
  285. check_recipe(
  286. "old",
  287. &r,
  288. &[Path::new("/nonexistent/docker-images")],
  289. &mut out,
  290. );
  291. // simulate the archived downgrade from validate_dir
  292. for x in out.iter_mut() {
  293. if x.level == Level::Error {
  294. x.level = Level::Warning;
  295. }
  296. }
  297. assert!(out.iter().all(|v| v.level == Level::Warning), "{out:?}");
  298. }
  299. }