feat: add servers description

This commit is contained in:
2026-08-23 07:31:48 +05:00
parent beab08cf67
commit 0374af34fa
3 changed files with 121 additions and 14 deletions
+15 -10
View File
@@ -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.<name>]``~/.ssh/config` → дефолт.
### Windows-нюанс TOML
+102 -3
View File
@@ -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<String, Server>,
pub projects: BTreeMap<String, Project>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Server {
pub host: String,
pub user: Option<String>,
pub port: Option<u16>,
pub key_path: Option<PathBuf>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Project {
pub workdir: PathBuf,
pub host: String,
pub server: Option<String>,
pub host: Option<String>,
pub user: Option<String>,
pub port: Option<u16>,
pub key_path: Option<PathBuf>,
@@ -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::<Vec<_>>().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#"
+4 -1
View File
@@ -114,7 +114,10 @@ fn print_plan(project: &Project, local_cmds: &[String], remote_cmds: &[String])
fn connect(project: &Project) -> Result<ssh::Session> {
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(),