| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226 |
- //! Tests for `promote` — hermetic `file://` git repos (no network).
- use super::{PromotePlan, execute, plan};
- use crate::config::{Config, RecipeRepo};
- use std::path::Path;
- fn git_ok(dir: &Path, args: &[&str]) {
- let out = std::process::Command::new("git")
- .arg("-C")
- .arg(dir)
- .args(args)
- .output()
- .expect("run git");
- assert!(
- out.status.success(),
- "git {args:?} in {dir:?} failed: {}",
- String::from_utf8_lossy(&out.stderr)
- );
- }
- /// A bare git repo (a `file://` "remote" — pushes land on it; a non-bare
- /// repo would reject pushes to its checked-out branch) with
- /// `recipes/demo/recipe.toml` committed.
- fn make_recipe_repo(tag: &str) -> (std::path::PathBuf, String) {
- let work =
- std::env::temp_dir().join(format!("sm121-promote-{tag}-work-{}", std::process::id()));
- let bare =
- std::env::temp_dir().join(format!("sm121-promote-{tag}-bare-{}", std::process::id()));
- let _ = std::fs::remove_dir_all(&work);
- let _ = std::fs::remove_dir_all(&bare);
- let recipe = work.join("recipes/demo");
- std::fs::create_dir_all(&recipe).unwrap();
- std::fs::write(
- recipe.join("recipe.toml"),
- "engine = \"vllm\"\nnote = \"demo\"\n",
- )
- .unwrap();
- let w = work.display().to_string();
- git_ok(&work, &["init", "-q"]);
- git_ok(&work, &["config", "user.email", "t@sm121.test"]);
- git_ok(&work, &["config", "user.name", "sm121-test"]);
- git_ok(&work, &["add", "-A"]);
- git_ok(&work, &["commit", "-qm", "init"]);
- git_ok(
- &work,
- &["clone", "--bare", &w, bare.display().to_string().as_str()],
- );
- (bare.clone(), format!("file://{}", bare.display()))
- }
- /// An empty (one README commit) bare git repo — a promotion target.
- fn make_empty_repo(tag: &str) -> (std::path::PathBuf, String) {
- let work = std::env::temp_dir().join(format!(
- "sm121-promote-empty-{tag}-work-{}",
- std::process::id()
- ));
- let bare = std::env::temp_dir().join(format!(
- "sm121-promote-empty-{tag}-bare-{}",
- std::process::id()
- ));
- let _ = std::fs::remove_dir_all(&work);
- let _ = std::fs::remove_dir_all(&bare);
- std::fs::create_dir_all(&work).unwrap();
- std::fs::write(work.join("README.md"), "target\n").unwrap();
- let w = work.display().to_string();
- git_ok(&work, &["init", "-q"]);
- git_ok(&work, &["config", "user.email", "t@sm121.test"]);
- git_ok(&work, &["config", "user.name", "sm121-test"]);
- git_ok(&work, &["add", "-A"]);
- git_ok(&work, &["commit", "-qm", "init"]);
- git_ok(
- &work,
- &["clone", "--bare", &w, bare.display().to_string().as_str()],
- );
- (bare.clone(), format!("file://{}", bare.display()))
- }
- fn config_with_repos(urls: &[String]) -> Config {
- let repos: Vec<RecipeRepo> = urls
- .iter()
- .map(|u| RecipeRepo {
- url: u.clone(),
- token: None,
- })
- .collect();
- Config {
- ssh_user: "u".to_string(),
- ssh_key_path: "/k".into(),
- clusters: vec![],
- nccl: std::collections::HashMap::new(),
- hf_cache_dir: None,
- init_port: 25000,
- before_serve: std::collections::HashMap::new(),
- exec_aliases: std::collections::HashMap::new(),
- home_assistant: None,
- mikrotik: None,
- global_mods_dir: None,
- drop_caches: true,
- mount_aliases: std::collections::HashMap::new(),
- recipe_sources: Vec::new(),
- recipe_repos: repos,
- docker_image_sources: Vec::new(),
- cluster_sources: Vec::new(),
- registry: None,
- }
- }
- /// The checkouts `plan` syncs into (default cache base, keyed by URL) —
- /// cleaned up after the test.
- fn checkouts(urls: &[String]) -> Vec<std::path::PathBuf> {
- urls.iter()
- .map(|u| crate::recipe_repos::repo_cache_dir(&crate::recipe_repos::cache_base(), u))
- .collect()
- }
- fn setup_identities(checkouts: &[std::path::PathBuf]) {
- for c in checkouts {
- if c.join(".git").is_dir() {
- git_ok(c, &["config", "user.email", "t@sm121.test"]);
- git_ok(c, &["config", "user.name", "sm121-test"]);
- }
- }
- }
- fn clean(c: &std::path::Path) {
- let _ = std::fs::remove_dir_all(c);
- }
- #[test]
- fn plan_prints_steps_and_apply_moves_the_recipe() {
- let (_src, a_url) = make_recipe_repo("src");
- let (_dst, b_url) = make_empty_repo("dst");
- let urls = vec![a_url.clone(), b_url.clone()];
- let cfg = config_with_repos(&urls);
- let p = plan(&cfg, "demo", &a_url, &b_url, true).expect("plan");
- assert_eq!(
- p.steps.len(),
- 7,
- "copy + (add, commit, push) + (rm, commit, push)"
- );
- assert_eq!(p.steps[0].side, "target", "target side runs first");
- let co = checkouts(&urls);
- setup_identities(&co);
- execute(&p).expect("execute");
- // target: the recipe landed + a promote commit exists
- assert!(co[1].join("recipes/demo/recipe.toml").is_file());
- let log = git_out(&co[1], &["log", "--format=%s"]);
- assert!(log.contains("promote demo"), "target log: {log}");
- // source: the recipe is gone + a remove commit exists
- assert!(!co[0].join("recipes/demo").exists());
- let log = git_out(&co[0], &["log", "--format=%s"]);
- assert!(log.contains("promote demo"), "source log: {log}");
- for c in &co {
- clean(c);
- }
- }
- fn git_out(dir: &Path, args: &[&str]) -> String {
- let out = std::process::Command::new("git")
- .arg("-C")
- .arg(dir)
- .args(args)
- .output()
- .expect("run git");
- String::from_utf8_lossy(&out.stdout).to_string()
- }
- #[test]
- fn dry_run_plan_has_no_side_effects() {
- let (_src, a_url) = make_recipe_repo("dry-src");
- let (_dst, b_url) = make_empty_repo("dry-dst");
- let urls = vec![a_url.clone(), b_url.clone()];
- let cfg = config_with_repos(&urls);
- // pre-sync both checkouts (a dry-run plan must NOT clone on demand)
- for u in &urls {
- let repo = &cfg.recipe_repos[urls.iter().position(|x| x == u).unwrap()];
- crate::recipe_repos::sync_repo(repo).expect("pre-sync");
- }
- let p: PromotePlan = plan(&cfg, "demo", &a_url, &b_url, false).expect("plan");
- assert!(
- !p.to_checkout.join("recipes/demo").exists(),
- "planning never copies"
- );
- assert!(p.from_checkout.join("recipes/demo/recipe.toml").is_file());
- let co = checkouts(&urls);
- for c in &co {
- clean(c);
- }
- }
- #[test]
- fn collision_in_target_is_refused() {
- let (_src, a_url) = make_recipe_repo("col-src");
- let (_dst, b_url) = make_recipe_repo("col-dst"); // the recipe exists in BOTH
- let urls = vec![a_url.clone(), b_url.clone()];
- let cfg = config_with_repos(&urls);
- let err = plan(&cfg, "demo", &a_url, &b_url, true).expect_err("collision must be refused");
- let msg = err.to_string();
- assert!(msg.contains("already exists"), "{msg}");
- let co = checkouts(&urls);
- for c in &co {
- clean(c);
- }
- }
- #[test]
- fn unknown_repo_url_is_refused() {
- let (_src, a_url) = make_recipe_repo("unk-src");
- let cfg = config_with_repos(std::slice::from_ref(&a_url));
- let err = plan(&cfg, "demo", &a_url, "file:///not-configured", true)
- .expect_err("unconfigured url must be refused");
- assert!(err.to_string().contains("not a configured"), "{}", err);
- clean(&checkouts(std::slice::from_ref(&a_url))[0]);
- }
- #[test]
- fn same_repo_for_from_and_to_is_refused() {
- let (_src, a_url) = make_recipe_repo("same");
- let cfg = config_with_repos(std::slice::from_ref(&a_url));
- let err =
- plan(&cfg, "demo", &a_url, &a_url, true).expect_err("same-repo promote must be refused");
- assert!(err.to_string().contains("different repos"), "{}", err);
- clean(&checkouts(&[a_url])[0]);
- }
|