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
+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(),