feat: simplify config toml, add local after commands
This commit is contained in:
+141
-29
@@ -29,18 +29,20 @@ pub struct Server {
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Project {
|
||||
pub workdir: PathBuf,
|
||||
pub branch: Option<String>,
|
||||
pub server: Option<String>,
|
||||
pub host: Option<String>,
|
||||
pub user: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
pub key_path: Option<PathBuf>,
|
||||
pub workdir: PathBuf,
|
||||
pub branch: Option<String>,
|
||||
#[serde(default)]
|
||||
pub env: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub local: Option<Commands>,
|
||||
#[serde(default)]
|
||||
pub local_after: Option<Commands>,
|
||||
#[serde(default, deserialize_with = "de_sync")]
|
||||
pub sync: Vec<SyncRule>,
|
||||
#[serde(default)]
|
||||
pub remote: Option<Commands>,
|
||||
@@ -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<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 {
|
||||
pub fn validate(mut self) -> Result<Self> {
|
||||
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<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> {
|
||||
@@ -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();
|
||||
|
||||
+141
-10
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user