diff --git a/README.md b/README.md index 3d64272..18b63c8 100644 --- a/README.md +++ b/README.md @@ -45,20 +45,22 @@ Deploy successful! (total 2.3s) Схема: ```toml -[projects.my-node-app] -workdir = "~/code/my-node-app" # где выполнять локальные команды (~ разворачивается) -host = "my-vps" # алиас из ~/.ssh/config или IP -user = "deploy" # опционально; иначе из ~/.ssh/config или юзер ОС -port = 2222 # опционально; иначе из ~/.ssh/config, иначе 22 -key_path = "~/.ssh/id_ed25519" # опционально; иначе identity_file из ssh-config, - # иначе ~/.ssh/id_rsa +[servers.my-vps] # профиль сервера: описывается один раз +host = "195.0.2.10" # алиас из ~/.ssh/config или IP +port = 2222 +user = "deploy" +key_path = "~/.ssh/id_ed25519" -[projects.my-node-app.local] # шаги сборки на вашей машине -commands = ["npm ci", "npm run build"] +[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 = "/var/www/pages" @@ -67,7 +69,10 @@ target = "/var/www/pages" commands = ["pm2 restart pages"] ``` -Приоритет параметров SSH: deploy.toml → `~/.ssh/config` → дефолт. +Проект может задать параметры подключения и напрямую (`host`, `user`, `port`, +`key_path` вместо `server`) — тогда они имеют приоритет над профилем. + +Приоритет параметров SSH: проект → `[servers.]` → `~/.ssh/config` → дефолт. ### Windows-нюанс TOML diff --git a/src/config.rs b/src/config.rs index 8810f33..dc41244 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,14 +11,26 @@ pub const GLOBAL_DIR: &str = "xboctploy"; #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { + #[serde(default)] + pub servers: BTreeMap, pub projects: BTreeMap, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Server { + pub host: String, + pub user: Option, + pub port: Option, + pub key_path: Option, +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct Project { pub workdir: PathBuf, - pub host: String, + pub server: Option, + pub host: Option, pub user: Option, pub port: Option, pub key_path: Option, @@ -50,8 +62,38 @@ impl Config { if self.projects.is_empty() { bail!("no [projects.*] sections defined"); } - for (name, project) in &mut self.projects { - if project.host.trim().is_empty() { + for (server_name, server) in &mut self.servers { + if let Some(kp) = &mut server.key_path { + *kp = expand_tilde(kp) + .with_context(|| format!("server '{server_name}': invalid key_path"))?; + } + } + let Self { servers, projects } = &mut self; + for (name, project) in projects.iter_mut() { + if let Some(server_name) = &project.server { + let Some(server) = servers.get(server_name) else { + let known = servers.keys().cloned().collect::>().join(", "); + bail!("project '{name}': unknown server '{server_name}' (defined: {known})"); + }; + if project.host.is_none() { + project.host = Some(server.host.clone()); + } + if project.user.is_none() { + project.user = server.user.clone(); + } + if project.port.is_none() { + project.port = server.port; + } + if project.key_path.is_none() { + project.key_path = server.key_path.clone(); + } + } + let Some(host) = &project.host else { + bail!( + "project '{name}': no host (set 'host' or reference a '[servers.*]' profile via 'server')" + ); + }; + if host.trim().is_empty() { bail!("project '{name}': host must not be empty"); } for (phase, cmds) in [ @@ -193,6 +235,63 @@ hostt = "typo" assert!(parse(s).is_err()); } + const SERVERS: &str = r#" +[servers.main] +host = "10.0.0.9" +port = 2327 +user = "deploy" +key_path = "~/.ssh/xbp_key" + +[projects.web] +workdir = "." +server = "main" + +[projects.api] +workdir = "." +server = "main" +port = 2222 +"#; + + #[test] + fn server_profile_fills_connection_params() { + let cfg = parse(SERVERS).unwrap(); + let web = cfg.projects.get("web").unwrap(); + assert_eq!(web.host.as_deref(), Some("10.0.0.9")); + assert_eq!(web.port, Some(2327)); + assert_eq!(web.user.as_deref(), Some("deploy")); + let key = web.key_path.as_ref().unwrap(); + assert!(key.ends_with("xbp_key"), "{}", key.display()); + } + + #[test] + fn project_fields_override_server_profile() { + let cfg = parse(SERVERS).unwrap(); + let api = cfg.projects.get("api").unwrap(); + assert_eq!(api.port, Some(2222)); + assert_eq!(api.user.as_deref(), Some("deploy")); + } + + #[test] + fn unknown_server_is_clear_error() { + let s = r#" +[projects.web] +workdir = "." +server = "nope" +"#; + let err = parse(s).unwrap_err(); + assert!(err.to_string().contains("unknown server 'nope'"), "{err:#}"); + } + + #[test] + fn missing_host_without_server_is_error() { + let s = r#" +[projects.web] +workdir = "." +"#; + let err = parse(s).unwrap_err(); + assert!(err.to_string().contains("no host"), "{err:#}"); + } + #[test] fn empty_commands_section_is_rejected() { let s = r#" diff --git a/src/deploy.rs b/src/deploy.rs index 28e64a0..56f604d 100644 --- a/src/deploy.rs +++ b/src/deploy.rs @@ -114,7 +114,10 @@ fn print_plan(project: &Project, local_cmds: &[String], remote_cmds: &[String]) fn connect(project: &Project) -> Result { let target = ssh::Target { - host_alias: project.host.clone(), + host_alias: project + .host + .clone() + .expect("host is guaranteed by config validation"), user: project.user.clone(), port: project.port, key_path: project.key_path.clone(),