//! 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 = 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 { 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]); }