promote_tests.rs 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. //! Tests for `promote` — hermetic `file://` git repos (no network).
  2. use super::{PromotePlan, execute, plan};
  3. use crate::config::{Config, RecipeRepo};
  4. use std::path::Path;
  5. fn git_ok(dir: &Path, args: &[&str]) {
  6. let out = std::process::Command::new("git")
  7. .arg("-C")
  8. .arg(dir)
  9. .args(args)
  10. .output()
  11. .expect("run git");
  12. assert!(
  13. out.status.success(),
  14. "git {args:?} in {dir:?} failed: {}",
  15. String::from_utf8_lossy(&out.stderr)
  16. );
  17. }
  18. /// A bare git repo (a `file://` "remote" — pushes land on it; a non-bare
  19. /// repo would reject pushes to its checked-out branch) with
  20. /// `recipes/demo/recipe.toml` committed.
  21. fn make_recipe_repo(tag: &str) -> (std::path::PathBuf, String) {
  22. let work =
  23. std::env::temp_dir().join(format!("sm121-promote-{tag}-work-{}", std::process::id()));
  24. let bare =
  25. std::env::temp_dir().join(format!("sm121-promote-{tag}-bare-{}", std::process::id()));
  26. let _ = std::fs::remove_dir_all(&work);
  27. let _ = std::fs::remove_dir_all(&bare);
  28. let recipe = work.join("recipes/demo");
  29. std::fs::create_dir_all(&recipe).unwrap();
  30. std::fs::write(
  31. recipe.join("recipe.toml"),
  32. "engine = \"vllm\"\nnote = \"demo\"\n",
  33. )
  34. .unwrap();
  35. let w = work.display().to_string();
  36. git_ok(&work, &["init", "-q"]);
  37. git_ok(&work, &["config", "user.email", "t@sm121.test"]);
  38. git_ok(&work, &["config", "user.name", "sm121-test"]);
  39. git_ok(&work, &["add", "-A"]);
  40. git_ok(&work, &["commit", "-qm", "init"]);
  41. git_ok(
  42. &work,
  43. &["clone", "--bare", &w, bare.display().to_string().as_str()],
  44. );
  45. (bare.clone(), format!("file://{}", bare.display()))
  46. }
  47. /// An empty (one README commit) bare git repo — a promotion target.
  48. fn make_empty_repo(tag: &str) -> (std::path::PathBuf, String) {
  49. let work = std::env::temp_dir().join(format!(
  50. "sm121-promote-empty-{tag}-work-{}",
  51. std::process::id()
  52. ));
  53. let bare = std::env::temp_dir().join(format!(
  54. "sm121-promote-empty-{tag}-bare-{}",
  55. std::process::id()
  56. ));
  57. let _ = std::fs::remove_dir_all(&work);
  58. let _ = std::fs::remove_dir_all(&bare);
  59. std::fs::create_dir_all(&work).unwrap();
  60. std::fs::write(work.join("README.md"), "target\n").unwrap();
  61. let w = work.display().to_string();
  62. git_ok(&work, &["init", "-q"]);
  63. git_ok(&work, &["config", "user.email", "t@sm121.test"]);
  64. git_ok(&work, &["config", "user.name", "sm121-test"]);
  65. git_ok(&work, &["add", "-A"]);
  66. git_ok(&work, &["commit", "-qm", "init"]);
  67. git_ok(
  68. &work,
  69. &["clone", "--bare", &w, bare.display().to_string().as_str()],
  70. );
  71. (bare.clone(), format!("file://{}", bare.display()))
  72. }
  73. fn config_with_repos(urls: &[String]) -> Config {
  74. let repos: Vec<RecipeRepo> = urls
  75. .iter()
  76. .map(|u| RecipeRepo {
  77. url: u.clone(),
  78. token: None,
  79. })
  80. .collect();
  81. Config {
  82. flare: None,
  83. ssh_user: "u".to_string(),
  84. ssh_key_path: "/k".into(),
  85. clusters: vec![],
  86. nccl: std::collections::HashMap::new(),
  87. hf_cache_dir: None,
  88. init_port: 25000,
  89. before_serve: std::collections::HashMap::new(),
  90. exec_aliases: std::collections::HashMap::new(),
  91. home_assistant: None,
  92. mikrotik: None,
  93. global_mods_dir: None,
  94. drop_caches: true,
  95. mount_aliases: std::collections::HashMap::new(),
  96. recipe_sources: Vec::new(),
  97. recipe_repos: repos,
  98. docker_image_sources: Vec::new(),
  99. cluster_sources: Vec::new(),
  100. registry: None,
  101. }
  102. }
  103. /// The checkouts `plan` syncs into (default cache base, keyed by URL) —
  104. /// cleaned up after the test.
  105. fn checkouts(urls: &[String]) -> Vec<std::path::PathBuf> {
  106. urls.iter()
  107. .map(|u| crate::recipe_repos::repo_cache_dir(&crate::recipe_repos::cache_base(), u))
  108. .collect()
  109. }
  110. fn setup_identities(checkouts: &[std::path::PathBuf]) {
  111. for c in checkouts {
  112. if c.join(".git").is_dir() {
  113. git_ok(c, &["config", "user.email", "t@sm121.test"]);
  114. git_ok(c, &["config", "user.name", "sm121-test"]);
  115. }
  116. }
  117. }
  118. fn clean(c: &std::path::Path) {
  119. let _ = std::fs::remove_dir_all(c);
  120. }
  121. #[test]
  122. fn plan_prints_steps_and_apply_moves_the_recipe() {
  123. let (_src, a_url) = make_recipe_repo("src");
  124. let (_dst, b_url) = make_empty_repo("dst");
  125. let urls = vec![a_url.clone(), b_url.clone()];
  126. let cfg = config_with_repos(&urls);
  127. let p = plan(&cfg, "demo", &a_url, &b_url, true).expect("plan");
  128. assert_eq!(
  129. p.steps.len(),
  130. 7,
  131. "copy + (add, commit, push) + (rm, commit, push)"
  132. );
  133. assert_eq!(p.steps[0].side, "target", "target side runs first");
  134. let co = checkouts(&urls);
  135. setup_identities(&co);
  136. execute(&p).expect("execute");
  137. // target: the recipe landed + a promote commit exists
  138. assert!(co[1].join("recipes/demo/recipe.toml").is_file());
  139. let log = git_out(&co[1], &["log", "--format=%s"]);
  140. assert!(log.contains("promote demo"), "target log: {log}");
  141. // source: the recipe is gone + a remove commit exists
  142. assert!(!co[0].join("recipes/demo").exists());
  143. let log = git_out(&co[0], &["log", "--format=%s"]);
  144. assert!(log.contains("promote demo"), "source log: {log}");
  145. for c in &co {
  146. clean(c);
  147. }
  148. }
  149. fn git_out(dir: &Path, args: &[&str]) -> String {
  150. let out = std::process::Command::new("git")
  151. .arg("-C")
  152. .arg(dir)
  153. .args(args)
  154. .output()
  155. .expect("run git");
  156. String::from_utf8_lossy(&out.stdout).to_string()
  157. }
  158. #[test]
  159. fn dry_run_plan_has_no_side_effects() {
  160. let (_src, a_url) = make_recipe_repo("dry-src");
  161. let (_dst, b_url) = make_empty_repo("dry-dst");
  162. let urls = vec![a_url.clone(), b_url.clone()];
  163. let cfg = config_with_repos(&urls);
  164. // pre-sync both checkouts (a dry-run plan must NOT clone on demand)
  165. for u in &urls {
  166. let repo = &cfg.recipe_repos[urls.iter().position(|x| x == u).unwrap()];
  167. crate::recipe_repos::sync_repo(repo).expect("pre-sync");
  168. }
  169. let p: PromotePlan = plan(&cfg, "demo", &a_url, &b_url, false).expect("plan");
  170. assert!(
  171. !p.to_checkout.join("recipes/demo").exists(),
  172. "planning never copies"
  173. );
  174. assert!(p.from_checkout.join("recipes/demo/recipe.toml").is_file());
  175. let co = checkouts(&urls);
  176. for c in &co {
  177. clean(c);
  178. }
  179. }
  180. #[test]
  181. fn collision_in_target_is_refused() {
  182. let (_src, a_url) = make_recipe_repo("col-src");
  183. let (_dst, b_url) = make_recipe_repo("col-dst"); // the recipe exists in BOTH
  184. let urls = vec![a_url.clone(), b_url.clone()];
  185. let cfg = config_with_repos(&urls);
  186. let err = plan(&cfg, "demo", &a_url, &b_url, true).expect_err("collision must be refused");
  187. let msg = err.to_string();
  188. assert!(msg.contains("already exists"), "{msg}");
  189. let co = checkouts(&urls);
  190. for c in &co {
  191. clean(c);
  192. }
  193. }
  194. #[test]
  195. fn unknown_repo_url_is_refused() {
  196. let (_src, a_url) = make_recipe_repo("unk-src");
  197. let cfg = config_with_repos(std::slice::from_ref(&a_url));
  198. let err = plan(&cfg, "demo", &a_url, "file:///not-configured", true)
  199. .expect_err("unconfigured url must be refused");
  200. assert!(err.to_string().contains("not a configured"), "{}", err);
  201. clean(&checkouts(std::slice::from_ref(&a_url))[0]);
  202. }
  203. #[test]
  204. fn same_repo_for_from_and_to_is_refused() {
  205. let (_src, a_url) = make_recipe_repo("same");
  206. let cfg = config_with_repos(std::slice::from_ref(&a_url));
  207. let err =
  208. plan(&cfg, "demo", &a_url, &a_url, true).expect_err("same-repo promote must be refused");
  209. assert!(err.to_string().contains("different repos"), "{}", err);
  210. clean(&checkouts(&[a_url])[0]);
  211. }