use std::collections::BTreeMap;
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone)]
pub struct App {
pub name: String,
pub path: String,
#[serde(default)]
pub commands: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dist_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gateway_port: Option<u16>,
#[serde(default)]
pub servers: Vec<String>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Server {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<Ssh>,
#[serde(default)]
pub accept_invalid_certs: bool,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub deploy: BTreeMap<String, DeployTarget>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct DeployTarget {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restart: Option<String>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Credential {
pub server: String,
pub kind: String,
pub login: String,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Ssh {
pub host: String,
#[serde(default = "default_ssh_port")]
pub port: u16,
pub user: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
}
fn default_ssh_port() -> u16 {
22
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Group {
pub name: String,
pub apps: Vec<String>,
}
#[derive(Serialize, Deserialize, Default)]
pub struct State {
#[serde(default)]
pub bindings: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gateway: Option<Gateway>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Gateway {
pub pid: u32,
pub ports: BTreeMap<u16, String>,
}
pub fn validate_name(name: &str) -> Result<()> {
let ok =
!name.is_empty() && name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') && !name.starts_with('-') && !name.ends_with('-');
if !ok {
bail!("invalid name '{name}': use lowercase letters, digits and dashes");
}
Ok(())
}
pub fn parse_ssh(spec: &str) -> Result<Ssh> {
let (user, rest) = spec
.split_once('@')
.ok_or_else(|| anyhow::anyhow!("invalid SSH spec '{spec}': expected user@host[:port]"))?;
let (host, port) = match rest.split_once(':') {
Some((host, port)) => (host, port.parse().map_err(|_| anyhow::anyhow!("invalid SSH port in '{spec}'"))?),
None => (rest, default_ssh_port()),
};
if user.is_empty() || host.is_empty() {
bail!("invalid SSH spec '{spec}': expected user@host[:port]");
}
Ok(Ssh {
host: host.to_string(),
port,
user: user.to_string(),
key: None,
})
}
pub fn validate_url(url: &str) -> Result<()> {
if !(url.starts_with("http://") || url.starts_with("https://")) {
bail!("invalid URL '{url}': must start with http:// or https://");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names() {
assert!(validate_name("myapp").is_ok());
assert!(validate_name("my-app-2").is_ok());
assert!(validate_name("").is_err());
assert!(validate_name("My App").is_err());
assert!(validate_name("-x").is_err());
}
#[test]
fn ssh_specs() {
let ssh = parse_ssh("deploy@staging.example.com").unwrap();
assert_eq!((ssh.user.as_str(), ssh.host.as_str(), ssh.port), ("deploy", "staging.example.com", 22));
let ssh = parse_ssh("root@10.0.0.1:2222").unwrap();
assert_eq!(ssh.port, 2222);
assert!(parse_ssh("nohost").is_err());
assert!(parse_ssh("user@host:notaport").is_err());
}
}