Compare commits

..
2 Commits
4 changed files with 344 additions and 65 deletions
+53 -17
View File
@@ -49,7 +49,7 @@ Deploy successful! (total 2.3s)
ищется по обычной цепочке, так что временный локальный `./deploy.toml` ищется по обычной цепочке, так что временный локальный `./deploy.toml`
переопределяет глобальный — создали файл, задеплоились, удалили. переопределяет глобальный — создали файл, задеплоились, удалили.
Схема: Схема**один проект = один блок**, все параметры точечными ключами:
```toml ```toml
[servers.my-vps] # профиль сервера: описывается один раз [servers.my-vps] # профиль сервера: описывается один раз
@@ -60,29 +60,65 @@ key_path = "~/.ssh/id_ed25519"
base_dir = "/var/www/pages" # корень для относительных sync target base_dir = "/var/www/pages" # корень для относительных sync target
[projects.my-node-app] [projects.my-node-app]
server = "my-vps" # ссылка на профиль server = "my-vps" # ссылка на профиль SSH
workdir = "~/code/my-node-app" # где выполнять локальные команды (~ разворачивается) workdir = 'C:\code\my-node-app' # где выполнять локальные команды (~ разворачивается)
branch = "main" # опционально: защита от деплоя чужой ветки
[projects.my-node-app.env] # переменные для локальных команд сборки env.PUBLIC_BASE_PATH = "/my-node-app" # переменные для локальной сборки
PUBLIC_BASE_PATH = "/my-node-app" local.commands = ["npm ci", "npm run build"] # шаги сборки на вашей машине
sync = "dist" # залить dist -> <base_dir>/my-node-app
[projects.my-node-app.local] # шаги сборки на вашей машине remote.commands = ["pm2 restart pages"] # команды управления по SSH
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"]
``` ```
### Поле `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`, Проект может задать параметры подключения и напрямую (`host`, `user`, `port`,
`key_path` вместо `server`) — тогда они имеют приоритет над профилем. `key_path` вместо `server`) — тогда они имеют приоритет над профилем.
Приоритет параметров SSH: проект → `[servers.<name>]``~/.ssh/config` → дефолт. Приоритет параметров SSH: проект → `[servers.<name>]``~/.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=%XD_DEPLOY_RESULT%"',
]
```
Внутри доступны переменные (в cmd — `%VAR%`, в sh — `$VAR`):
- `XD_DEPLOY_RESULT``success` либо `failed`;
- `XD_ERROR` — текст ошибки (только при `failed`).
Эти же переменные доступны в `--dry-run`, где показываются в плане без
выполнения.
### Windows-нюанс TOML ### Windows-нюанс TOML
В basic-строках `"..."` бэкслеши — escape-символы, путь `C:\Users` сломает парсинг. В basic-строках `"..."` бэкслеши — escape-символы, путь `C:\Users` сломает парсинг.
+143 -31
View File
@@ -29,18 +29,20 @@ pub struct Server {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Project { pub struct Project {
pub workdir: PathBuf,
pub branch: Option<String>,
pub server: Option<String>, pub server: Option<String>,
pub host: Option<String>, pub host: Option<String>,
pub user: Option<String>, pub user: Option<String>,
pub port: Option<u16>, pub port: Option<u16>,
pub key_path: Option<PathBuf>, pub key_path: Option<PathBuf>,
pub workdir: PathBuf,
pub branch: Option<String>,
#[serde(default)] #[serde(default)]
pub env: BTreeMap<String, String>, pub env: BTreeMap<String, String>,
#[serde(default)] #[serde(default)]
pub local: Option<Commands>, pub local: Option<Commands>,
#[serde(default)] #[serde(default)]
pub local_after: Option<Commands>,
#[serde(default, deserialize_with = "de_sync")]
pub sync: Vec<SyncRule>, pub sync: Vec<SyncRule>,
#[serde(default)] #[serde(default)]
pub remote: Option<Commands>, pub remote: Option<Commands>,
@@ -56,9 +58,40 @@ pub struct Commands {
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct SyncRule { pub struct SyncRule {
pub source: PathBuf, pub source: PathBuf,
#[serde(default)]
pub target: PathBuf, pub target: PathBuf,
} }
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum SyncSpec {
Source(PathBuf),
Sources(Vec<PathBuf>),
Rule(SyncRule),
Rules(Vec<SyncRule>),
}
fn de_sync<'de, D>(deserializer: D) -> Result<Vec<SyncRule>, 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 { impl Config {
pub fn validate(mut self) -> Result<Self> { pub fn validate(mut self) -> Result<Self> {
if self.projects.is_empty() { if self.projects.is_empty() {
@@ -105,6 +138,7 @@ impl Config {
} }
for (phase, cmds) in [ for (phase, cmds) in [
("local", &mut project.local), ("local", &mut project.local),
("local_after", &mut project.local_after),
("remote", &mut project.remote), ("remote", &mut project.remote),
] { ] {
if let Some(c) = cmds if let Some(c) = cmds
@@ -119,6 +153,11 @@ impl Config {
*kp = expand_tilde(kp) *kp = expand_tilde(kp)
.with_context(|| format!("project '{name}': invalid key_path"))?; .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 { for rule in &mut project.sync {
rule.source = expand_tilde(&rule.source) rule.source = expand_tilde(&rule.source)
.with_context(|| format!("project '{name}': invalid sync 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<PathBuf> { pub fn global_config_path(home: Option<&Path>) -> Option<PathBuf> {
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<PathBuf> { pub fn global_path(home: Option<&Path>) -> Option<PathBuf> {
@@ -244,18 +288,12 @@ mod tests {
const VALID: &str = r#" const VALID: &str = r#"
[projects.web] [projects.web]
workdir = "~/code/web"
host = "vps" host = "vps"
workdir = "~/code/web"
[projects.web.local] env.PUBLIC_BASE_PATH = "/web"
commands = ["npm run build"] local.commands = ["npm run build"]
sync = [{ source = "dist", target = "/var/www/web" }]
[[projects.web.sync]] remote.commands = ["pm2 restart web"]
source = "dist"
target = "/var/www/web"
[projects.web.remote]
commands = ["pm2 restart web"]
"#; "#;
#[test] #[test]
@@ -343,7 +381,7 @@ hostt = "typo"
host = "10.0.0.9" host = "10.0.0.9"
port = 2327 port = 2327
user = "deploy" user = "deploy"
key_path = "~/.ssh/xbp_key" key_path = "~/.ssh/xd_key"
[projects.web] [projects.web]
workdir = "." workdir = "."
@@ -363,7 +401,7 @@ port = 2222
assert_eq!(web.port, Some(2327)); assert_eq!(web.port, Some(2327));
assert_eq!(web.user.as_deref(), Some("deploy")); assert_eq!(web.user.as_deref(), Some("deploy"));
let key = web.key_path.as_ref().unwrap(); let key = web.key_path.as_ref().unwrap();
assert!(key.ends_with("xbp_key"), "{}", key.display()); assert!(key.ends_with("xd_key"), "{}", key.display());
} }
#[test] #[test]
@@ -403,16 +441,12 @@ host = "10.0.0.9"
base_dir = "/var/www/pages/" base_dir = "/var/www/pages/"
[projects.web] [projects.web]
workdir = "."
server = "main" server = "main"
workdir = "."
[[projects.web.sync]] sync = [
source = "dist" { source = "dist", target = "web-app" },
target = "web-app" { source = "dist/favicon.ico", target = "/opt/static/favicon.ico" },
]
[[projects.web.sync]]
source = "dist/favicon.ico"
target = "/opt/static/favicon.ico"
"#; "#;
let cfg = parse(s).unwrap(); let cfg = parse(s).unwrap();
let sync = &cfg.projects.get("web").unwrap().sync; let sync = &cfg.projects.get("web").unwrap().sync;
@@ -426,10 +460,7 @@ target = "/opt/static/favicon.ico"
[projects.web] [projects.web]
workdir = "." workdir = "."
host = "10.0.0.9" host = "10.0.0.9"
sync = [{ source = "dist", target = "web-app" }]
[[projects.web.sync]]
source = "dist"
target = "web-app"
"#; "#;
let err = parse(s).unwrap_err(); let err = parse(s).unwrap_err();
assert!(err.to_string().contains("base_dir"), "{err:#}"); assert!(err.to_string().contains("base_dir"), "{err:#}");
@@ -441,12 +472,93 @@ target = "web-app"
[projects.web] [projects.web]
workdir = "." workdir = "."
host = "vps" host = "vps"
[projects.web.remote] remote.commands = []
commands = []
"#; "#;
assert!(parse(s).is_err()); 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] #[test]
fn tilde_expands_to_home() { fn tilde_expands_to_home() {
let home = home::home_dir().unwrap(); let home = home::home_dir().unwrap();
+141 -10
View File
@@ -21,19 +21,51 @@ pub fn deploy(name: &str, project: &Project, dry_run: bool) -> Result<()> {
.remote .remote
.as_ref() .as_ref()
.map_or([].as_slice(), |c| c.commands.as_slice()); .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 { if dry_run {
print_plan(project, local_cmds, remote_cmds); print_plan(project, local_cmds, remote_cmds, local_after_cmds);
return Ok(()); return Ok(());
} }
if local_cmds.is_empty() && project.sync.is_empty() && remote_cmds.is_empty() { if local_cmds.is_empty()
println!("nothing to do: no local commands, sync rules or remote commands"); && 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(()); return Ok(());
} }
let total = Instant::now(); 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 { for cmd in local_cmds {
let step = Instant::now(); let step = Instant::now();
if let Err(err) = local::execute(cmd, &project.workdir, &project.env) { 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!( Ok(())
"{} (total {})", }
"Deploy successful!".green().bold(),
fmt_dur(total.elapsed()) 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("XD_DEPLOY_RESULT".to_string(), "success".to_string());
}
Err(err) => {
env.insert("XD_DEPLOY_RESULT".to_string(), "failed".to_string());
env.insert("XD_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(()) 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:"); println!("dry-run plan:");
for cmd in local_cmds { for cmd in local_cmds {
println!(" [Local] {cmd}"); println!(" [Local] {cmd}");
@@ -136,6 +202,9 @@ fn print_plan(project: &Project, local_cmds: &[String], remote_cmds: &[String])
for cmd in remote_cmds { for cmd in remote_cmds {
println!(" [Remote] {cmd}"); println!(" [Remote] {cmd}");
} }
for cmd in local_after_cmds {
println!(" [LocalAfter] {cmd}");
}
println!("{}", "nothing executed".dimmed()); println!("{}", "nothing executed".dimmed());
} }
@@ -185,7 +254,7 @@ fn fmt_dur(d: Duration) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::Project; use crate::config::{Commands, Project};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
@@ -238,11 +307,73 @@ mod tests {
key_path: None, key_path: None,
env: Default::default(), env: Default::default(),
local: None, local: None,
local_after: None,
sync: Vec::new(), sync: Vec::new(),
remote: None, remote: None,
} }
} }
fn result_echo_cmd(out: &Path) -> String {
if cfg!(windows) {
format!("echo %XD_DEPLOY_RESULT%> {}", out.display())
} else {
format!("echo $XD_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] #[test]
fn no_branch_deploys_without_check() { fn no_branch_deploys_without_check() {
let dir = tmpdir("nobranch"); let dir = tmpdir("nobranch");
+7 -7
View File
@@ -212,11 +212,11 @@ mod tests {
fn env_vars_visible_inside_local_commands() { fn env_vars_visible_inside_local_commands() {
let workdir = tmpdir("dotenv-exec"); let workdir = tmpdir("dotenv-exec");
let echo_var = if cfg!(windows) { let echo_var = if cfg!(windows) {
"echo %XBP_TEST_VAR%" "echo %XD_TEST_VAR%"
} else { } else {
"echo $XBP_TEST_VAR" "echo $XD_TEST_VAR"
}; };
fs::write(workdir.join(".env"), "XBP_TEST_VAR=hello-env\n").unwrap(); fs::write(workdir.join(".env"), "XD_TEST_VAR=hello-env\n").unwrap();
let mut command = shell_command(echo_var); let mut command = shell_command(echo_var);
command.current_dir(&workdir); command.current_dir(&workdir);
for (k, v) in load_dotenv(&workdir).unwrap() { for (k, v) in load_dotenv(&workdir).unwrap() {
@@ -233,13 +233,13 @@ mod tests {
fn config_env_overrides_dotenv() { fn config_env_overrides_dotenv() {
let workdir = tmpdir("dotenv-precedence"); let workdir = tmpdir("dotenv-precedence");
let echo_var = if cfg!(windows) { let echo_var = if cfg!(windows) {
"echo %XBP_P_VAR%" "echo %XD_P_VAR%"
} else { } else {
"echo $XBP_P_VAR" "echo $XD_P_VAR"
}; };
fs::write(workdir.join(".env"), "XBP_P_VAR=from-dotenv\n").unwrap(); fs::write(workdir.join(".env"), "XD_P_VAR=from-dotenv\n").unwrap();
let mut extra = BTreeMap::new(); let mut extra = BTreeMap::new();
extra.insert("XBP_P_VAR".to_string(), "from-config".to_string()); extra.insert("XD_P_VAR".to_string(), "from-config".to_string());
let mut command = shell_command(echo_var); let mut command = shell_command(echo_var);
command.current_dir(&workdir); command.current_dir(&workdir);
for (k, v) in load_dotenv(&workdir).unwrap() { for (k, v) in load_dotenv(&workdir).unwrap() {