diff --git a/README.md b/README.md index 311c0f0..060a13c 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Deploy successful! (total 2.3s) ищется по обычной цепочке, так что временный локальный `./deploy.toml` переопределяет глобальный — создали файл, задеплоились, удалили. -Схема: +Схема — **один проект = один блок**, все параметры точечными ключами: ```toml [servers.my-vps] # профиль сервера: описывается один раз @@ -60,29 +60,65 @@ key_path = "~/.ssh/id_ed25519" base_dir = "/var/www/pages" # корень для относительных sync target [projects.my-node-app] -server = "my-vps" # ссылка на профиль -workdir = "~/code/my-node-app" # где выполнять локальные команды (~ разворачивается) - -[projects.my-node-app.env] # переменные для локальных команд сборки -PUBLIC_BASE_PATH = "/my-node-app" - -[projects.my-node-app.local] # шаги сборки на вашей машине -commands = ["npm ci", "npm run build"] - -[[projects.my-node-app.sync]] # что заливать на сервер -source = "dist" # файл или папка (относительно workdir) -target = "my-node-app" # относительный -> base_dir сервера; - # абсолютный "/path" используется как есть - -[projects.my-node-app.remote] # команды управления на сервере -commands = ["pm2 restart pages"] +server = "my-vps" # ссылка на профиль SSH +workdir = 'C:\code\my-node-app' # где выполнять локальные команды (~ разворачивается) +branch = "main" # опционально: защита от деплоя чужой ветки +env.PUBLIC_BASE_PATH = "/my-node-app" # переменные для локальной сборки +local.commands = ["npm ci", "npm run build"] # шаги сборки на вашей машине +sync = "dist" # залить dist -> /my-node-app +remote.commands = ["pm2 restart pages"] # команды управления по SSH ``` +### Поле `sync` + +Три формы — выбирай по смыслу: + +```toml +sync = "dist" # один источник; target = имя проекта +sync = ["dist", "static"] # несколько источников; target = имя проекта +sync = [{ source = "web/build", target = "/opt/static" }] # полные правила +``` + +Во всех формах `target` без `/` на конце резолвится через `base_dir` сервера +(`"my-node-app"` → `/var/www/pages/my-node-app`). Абсолютный target +(`"/opt/static"`) используется как есть. Отдельных блоков `[[...sync]]`, +`[projects.x.env]`, `[projects.x.local]`, `[projects.x.remote]` больше нет — +это не нужно помнить и копировать, всё в блоке проекта. + Проект может задать параметры подключения и напрямую (`host`, `user`, `port`, `key_path` вместо `server`) — тогда они имеют приоритет над профилем. Приоритет параметров SSH: проект → `[servers.]` → `~/.ssh/config` → дефолт. +### Локальные команды после деплоя (`local_after`) + +Выполняются на вашей машине **после** sync + remote и **всегда** — и при +успехе, и после любого упавшего шага (deploy всё равно упадёт). Для очистки +артефактов или вебхука с результатом: + +```toml +[projects.my-node-app] +server = "my-vps" +workdir = 'C:\code\my-node-app' +local.commands = ["npm run build"] +sync = "dist" +remote.commands = ["pm2 restart pages"] + +[projects.my-node-app.local_after] +commands = [ + 'del /q dist', + 'curl -s -X POST https://example.com/hook -d "result=%XBP_DEPLOY_RESULT%"', +] +``` + +Внутри доступны переменные (в cmd — `%VAR%`, в sh — `$VAR`): + +- `XBP_DEPLOY_RESULT` — `success` либо `failed`; +- `XBP_ERROR` — текст ошибки (только при `failed`). + +Эти же переменные доступны в `--dry-run`, где показываются в плане без +выполнения. + ### Windows-нюанс TOML В basic-строках `"..."` бэкслеши — escape-символы, путь `C:\Users` сломает парсинг. diff --git a/src/config.rs b/src/config.rs index 5f7bce9..3fdbbaf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -29,18 +29,20 @@ pub struct Server { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct Project { - pub workdir: PathBuf, - pub branch: Option, pub server: Option, pub host: Option, pub user: Option, pub port: Option, pub key_path: Option, + pub workdir: PathBuf, + pub branch: Option, #[serde(default)] pub env: BTreeMap, #[serde(default)] pub local: Option, #[serde(default)] + pub local_after: Option, + #[serde(default, deserialize_with = "de_sync")] pub sync: Vec, #[serde(default)] pub remote: Option, @@ -56,9 +58,40 @@ pub struct Commands { #[serde(deny_unknown_fields)] pub struct SyncRule { pub source: PathBuf, + #[serde(default)] pub target: PathBuf, } +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum SyncSpec { + Source(PathBuf), + Sources(Vec), + Rule(SyncRule), + Rules(Vec), +} + +fn de_sync<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::de::Deserializer<'de>, +{ + Ok(match SyncSpec::deserialize(deserializer)? { + SyncSpec::Source(source) => vec![SyncRule { + source, + target: PathBuf::new(), + }], + SyncSpec::Sources(sources) => sources + .into_iter() + .map(|source| SyncRule { + source, + target: PathBuf::new(), + }) + .collect(), + SyncSpec::Rule(rule) => vec![rule], + SyncSpec::Rules(rules) => rules, + }) +} + impl Config { pub fn validate(mut self) -> Result { if self.projects.is_empty() { @@ -105,6 +138,7 @@ impl Config { } for (phase, cmds) in [ ("local", &mut project.local), + ("local_after", &mut project.local_after), ("remote", &mut project.remote), ] { if let Some(c) = cmds @@ -119,6 +153,11 @@ impl Config { *kp = expand_tilde(kp) .with_context(|| format!("project '{name}': invalid key_path"))?; } + for rule in &mut project.sync { + if rule.target.as_os_str().is_empty() { + rule.target = PathBuf::from(name); + } + } for rule in &mut project.sync { rule.source = expand_tilde(&rule.source) .with_context(|| format!("project '{name}': invalid sync source"))?; @@ -185,7 +224,12 @@ pub fn display(path: &Path) -> String { } pub fn global_config_path(home: Option<&Path>) -> Option { - Some(home?.join(".config").join(GLOBAL_DIR).join(CONFIG_FILE_NAME)) + Some( + home? + .join(".config") + .join(GLOBAL_DIR) + .join(CONFIG_FILE_NAME), + ) } pub fn global_path(home: Option<&Path>) -> Option { @@ -244,18 +288,12 @@ mod tests { const VALID: &str = r#" [projects.web] -workdir = "~/code/web" host = "vps" - -[projects.web.local] -commands = ["npm run build"] - -[[projects.web.sync]] -source = "dist" -target = "/var/www/web" - -[projects.web.remote] -commands = ["pm2 restart web"] +workdir = "~/code/web" +env.PUBLIC_BASE_PATH = "/web" +local.commands = ["npm run build"] +sync = [{ source = "dist", target = "/var/www/web" }] +remote.commands = ["pm2 restart web"] "#; #[test] @@ -403,16 +441,12 @@ host = "10.0.0.9" base_dir = "/var/www/pages/" [projects.web] -workdir = "." server = "main" - -[[projects.web.sync]] -source = "dist" -target = "web-app" - -[[projects.web.sync]] -source = "dist/favicon.ico" -target = "/opt/static/favicon.ico" +workdir = "." +sync = [ + { source = "dist", target = "web-app" }, + { source = "dist/favicon.ico", target = "/opt/static/favicon.ico" }, +] "#; let cfg = parse(s).unwrap(); let sync = &cfg.projects.get("web").unwrap().sync; @@ -426,10 +460,7 @@ target = "/opt/static/favicon.ico" [projects.web] workdir = "." host = "10.0.0.9" - -[[projects.web.sync]] -source = "dist" -target = "web-app" +sync = [{ source = "dist", target = "web-app" }] "#; let err = parse(s).unwrap_err(); assert!(err.to_string().contains("base_dir"), "{err:#}"); @@ -441,12 +472,93 @@ target = "web-app" [projects.web] workdir = "." host = "vps" -[projects.web.remote] -commands = [] +remote.commands = [] "#; assert!(parse(s).is_err()); } + #[test] + fn short_sync_uses_project_name_as_target() { + let s = r#" +[servers.main] +host = "10.0.0.9" +base_dir = "/var/www/pages" + +[projects.web] +server = "main" +workdir = "." +sync = "dist" +"#; + let cfg = parse(s).unwrap(); + let rule = &cfg.projects.get("web").unwrap().sync[0]; + assert_eq!(rule.source.to_string_lossy(), "dist"); + assert_eq!(rule.target.to_string_lossy(), "/var/www/pages/web"); + } + + #[test] + fn sync_source_list_repeats_project_name_as_target() { + let s = r#" +[servers.main] +host = "10.0.0.9" +base_dir = "/var/www/pages" + +[projects.web] +server = "main" +workdir = "." +sync = ["dist", "static"] +"#; + let cfg = parse(s).unwrap(); + let sync = &cfg.projects.get("web").unwrap().sync; + assert_eq!(sync.len(), 2); + for rule in sync { + assert_eq!(rule.target.to_string_lossy(), "/var/www/pages/web"); + } + assert_eq!(sync[0].source.to_string_lossy(), "dist"); + assert_eq!(sync[1].source.to_string_lossy(), "static"); + } + + #[test] + fn sync_table_without_target_uses_project_name() { + let s = r#" +[servers.main] +host = "10.0.0.9" +base_dir = "/var/www/pages" + +[projects.web] +server = "main" +workdir = "." +sync = [{ source = "web/build" }] +"#; + let cfg = parse(s).unwrap(); + let rule = &cfg.projects.get("web").unwrap().sync[0]; + assert_eq!(rule.source.to_string_lossy(), "web/build"); + assert_eq!(rule.target.to_string_lossy(), "/var/www/pages/web"); + } + + #[test] + fn local_after_parses_and_validates() { + let s = r#" +[projects.web] +workdir = "." +host = "vps" +local_after.commands = ["del /q dist"] +"#; + let cfg = parse(s).unwrap(); + let project = cfg.projects.get("web").unwrap(); + assert_eq!( + project.local_after.as_ref().unwrap().commands, + vec!["del /q dist".to_string()] + ); + + let empty = r#" +[projects.web] +workdir = "." +host = "vps" +local_after.commands = [] +"#; + assert!(parse(empty).is_err()); + } + #[test] fn tilde_expands_to_home() { let home = home::home_dir().unwrap(); diff --git a/src/deploy.rs b/src/deploy.rs index 923173d..0907769 100644 --- a/src/deploy.rs +++ b/src/deploy.rs @@ -21,19 +21,51 @@ pub fn deploy(name: &str, project: &Project, dry_run: bool) -> Result<()> { .remote .as_ref() .map_or([].as_slice(), |c| c.commands.as_slice()); + let local_after_cmds = project + .local_after + .as_ref() + .map_or([].as_slice(), |c| c.commands.as_slice()); if dry_run { - print_plan(project, local_cmds, remote_cmds); + print_plan(project, local_cmds, remote_cmds, local_after_cmds); return Ok(()); } - if local_cmds.is_empty() && project.sync.is_empty() && remote_cmds.is_empty() { - println!("nothing to do: no local commands, sync rules or remote commands"); + if local_cmds.is_empty() + && project.sync.is_empty() + && remote_cmds.is_empty() + && local_after_cmds.is_empty() + { + println!( + "nothing to do: no local commands, sync rules, remote commands or post-deploy commands" + ); return Ok(()); } let total = Instant::now(); + let primary = run_deploy_steps(project, local_cmds, remote_cmds); + let after = run_local_after(project, &primary); + + match (primary.is_ok(), after.is_ok()) { + (true, true) => { + println!( + "{} (total {})", + "Deploy successful!".green().bold(), + fmt_dur(total.elapsed()) + ); + Ok(()) + } + (false, _) => Err(primary.unwrap_err()), + (true, false) => Err(after.unwrap_err()), + } +} + +fn run_deploy_steps( + project: &Project, + local_cmds: &[String], + remote_cmds: &[String], +) -> Result<()> { for cmd in local_cmds { let step = Instant::now(); if let Err(err) = local::execute(cmd, &project.workdir, &project.env) { @@ -90,11 +122,40 @@ pub fn deploy(name: &str, project: &Project, dry_run: bool) -> Result<()> { } } - println!( - "{} (total {})", - "Deploy successful!".green().bold(), - fmt_dur(total.elapsed()) - ); + Ok(()) +} + +fn run_local_after(project: &Project, primary: &Result<()>) -> Result<()> { + let Some(cmds) = &project.local_after else { + return Ok(()); + }; + let mut env = project.env.clone(); + match primary { + Ok(()) => { + env.insert("XBP_DEPLOY_RESULT".to_string(), "success".to_string()); + } + Err(err) => { + env.insert("XBP_DEPLOY_RESULT".to_string(), "failed".to_string()); + env.insert("XBP_ERROR".to_string(), format!("{err:#}")); + } + } + for cmd in &cmds.commands { + let step = Instant::now(); + if let Err(err) = local::execute(cmd, &project.workdir, &env) { + fail_line( + "[LocalAfter]", + &format!("running: {cmd}..."), + err.to_string(), + ); + return Err(err); + } + success_line( + "[LocalAfter]", + &format!("running: {cmd}..."), + "Success!", + step.elapsed(), + ); + } Ok(()) } @@ -121,7 +182,12 @@ fn verify_branch(name: &str, project: &Project, expected: &str) -> Result<()> { } } -fn print_plan(project: &Project, local_cmds: &[String], remote_cmds: &[String]) { +fn print_plan( + project: &Project, + local_cmds: &[String], + remote_cmds: &[String], + local_after_cmds: &[String], +) { println!("dry-run plan:"); for cmd in local_cmds { println!(" [Local] {cmd}"); @@ -136,6 +202,9 @@ fn print_plan(project: &Project, local_cmds: &[String], remote_cmds: &[String]) for cmd in remote_cmds { println!(" [Remote] {cmd}"); } + for cmd in local_after_cmds { + println!(" [LocalAfter] {cmd}"); + } println!("{}", "nothing executed".dimmed()); } @@ -185,7 +254,7 @@ fn fmt_dur(d: Duration) -> String { #[cfg(test)] mod tests { use super::*; - use crate::config::Project; + use crate::config::{Commands, Project}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; @@ -238,11 +307,73 @@ mod tests { key_path: None, env: Default::default(), local: None, + local_after: None, sync: Vec::new(), remote: None, } } + fn result_echo_cmd(out: &Path) -> String { + if cfg!(windows) { + format!("echo %XBP_DEPLOY_RESULT%> {}", out.display()) + } else { + format!("echo $XBP_DEPLOY_RESULT > {}", out.display()) + } + } + + fn fail_cmd() -> String { + if cfg!(windows) { + "exit /b 3".to_string() + } else { + "false".to_string() + } + } + + #[test] + fn local_after_runs_on_success_with_result() { + let dir = tmpdir("afterok"); + let out = dir.join("result.txt"); + let mut project = project_with_branch(dir.clone(), None); + project.local_after = Some(Commands { + commands: vec![result_echo_cmd(&out)], + }); + deploy("p", &project, false).unwrap(); + let content = fs::read_to_string(&out).unwrap(); + assert_eq!(content.trim(), "success"); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn local_after_runs_on_failure_with_result() { + let dir = tmpdir("afterfail"); + let out = dir.join("result.txt"); + let mut project = project_with_branch(dir.clone(), None); + project.local = Some(Commands { + commands: vec![fail_cmd()], + }); + project.local_after = Some(Commands { + commands: vec![result_echo_cmd(&out)], + }); + let err = deploy("p", &project, false).unwrap_err(); + assert!(err.to_string().contains("FAILED"), "{err:#}"); + let content = fs::read_to_string(&out).unwrap(); + assert_eq!(content.trim(), "failed"); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn dry_run_lists_local_after_but_does_not_execute() { + let dir = tmpdir("afterdry"); + let out = dir.join("result.txt"); + let mut project = project_with_branch(dir.clone(), None); + project.local_after = Some(Commands { + commands: vec![result_echo_cmd(&out)], + }); + deploy("p", &project, true).unwrap(); + assert!(!out.exists()); + let _ = fs::remove_dir_all(dir); + } + #[test] fn no_branch_deploys_without_check() { let dir = tmpdir("nobranch");