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, skip_serializing_if = "Option::is_none")]
pub gateway_env: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env_file: Option<String>,
#[serde(default)]
pub servers: Vec<String>,
}
pub const DEFAULT_GATEWAY_ENV: &str = "TURNOUT_GATEWAY_URL";
pub const DEFAULT_ENV_FILE: &str = ".env.development.local";
impl App {
pub fn gateway_url(&self) -> Option<String> {
self.gateway_port.map(|port| format!("http://localhost:{port}"))
}
pub fn gateway_env_name(&self) -> &str {
self.gateway_env.as_deref().unwrap_or(DEFAULT_GATEWAY_ENV)
}
pub fn env_file_name(&self) -> &str {
self.env_file.as_deref().unwrap_or(DEFAULT_ENV_FILE)
}
}
#[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 host: Option<String>,
#[serde(default = "default_ssh_port")]
pub port: u16,
#[serde(default)]
pub accept_invalid_certs: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shell: Option<crate::shell::Dialect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credential: Option<String>,
}
impl Server {
pub fn ssh_host(&self) -> String {
self.host.clone().unwrap_or_else(|| url_host(&self.url).to_string())
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Credential {
pub name: String,
pub user: String,
#[serde(default)]
pub auth: Auth,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
#[serde(rename_all = "lowercase")]
pub enum Auth {
#[default]
Password,
Key,
Agent,
}
impl Auth {
pub fn as_str(self) -> &'static str {
match self {
Auth::Password => "password",
Auth::Key => "key",
Auth::Agent => "agent",
}
}
}
impl std::fmt::Display for Auth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for Auth {
type Err = anyhow::Error;
fn from_str(text: &str) -> Result<Self> {
match text {
"password" => Ok(Auth::Password),
"key" => Ok(Auth::Key),
"agent" => Ok(Auth::Agent),
other => bail!("unknown auth kind '{other}': expected 'password', 'key' or 'agent'"),
}
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Path {
pub name: String,
pub dir: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restart: Option<String>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Target {
pub name: String,
pub app: String,
pub server: String,
pub credential: String,
pub path: String,
}
pub fn target_name(app: &str, server: &str) -> String {
format!("{app}-{server}")
}
pub fn unique_target_name(app: &str, server: &str, taken: &[Target]) -> String {
let base = target_name(app, server);
if !taken.iter().any(|b| b.name == base) {
return base;
}
(2..)
.map(|n| format!("{base}-{n}"))
.find(|name| !taken.iter().any(|b| &b.name == name))
.expect("an unused suffix exists")
}
fn default_ssh_port() -> u16 {
22
}
fn url_host(url: &str) -> &str {
let rest = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
let host = authority.rsplit_once('@').map(|(_, host)| host).unwrap_or(authority);
match host.rsplit_once(':') {
Some((before, after)) if !after.is_empty() && after.chars().all(|c| c.is_ascii_digit()) => before,
_ => host,
}
}
#[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_host(spec: &str) -> Result<(String, u16)> {
let spec = spec.trim();
if let Some((user, rest)) = spec.split_once('@') {
bail!(
"'{spec}' names a user: since v0.9.0 the login lives in a credential, not the server.\n\
Use the host alone (`{rest}`) and `turnout credential add --user {user}` for who logs in."
);
}
let (host, port) = match spec.rsplit_once(':') {
Some((host, port)) => (host, port.parse().map_err(|_| anyhow::anyhow!("invalid SSH port in '{spec}'"))?),
None => (spec, default_ssh_port()),
};
if host.is_empty() {
bail!("invalid host '{spec}': expected host[:port]");
}
Ok((host.to_string(), port))
}
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(())
}
pub fn normalize_remote_path(path: &str) -> String {
let path = path.trim();
match path.strip_prefix("//") {
Some(rest) if !rest.starts_with('/') => format!("/{rest}"),
_ => path.to_string(),
}
}
pub fn validate_remote_path(path: &str) -> Result<()> {
if path.trim().is_empty() {
bail!("remote path is empty: pass an absolute path on the server, e.g. /var/www/myapp");
}
if let Some(original) = looks_rewritten_by_git_bash(path) {
bail!(
"'{path}' looks like Git Bash rewrote '{original}' into a local path before turnout saw it.\n\
Prefix the command with MSYS_NO_PATHCONV=1, double the leading slash (/{original}), or use PowerShell.\n\
If you really meant this directory on a Windows server, pass it with backslashes."
);
}
if !is_absolute_remote(path) {
bail!("remote path '{path}' must be absolute, e.g. /var/www/myapp or C:\\inetpub\\myapp");
}
Ok(())
}
fn is_absolute_remote(path: &str) -> bool {
path.starts_with('/') || has_drive_letter(path) || path.starts_with("\\\\")
}
fn looks_rewritten_by_git_bash(path: &str) -> Option<&str> {
if !has_drive_letter(path) {
return None;
}
let normalized = path.replace('\\', "/");
const MSYS_ROOTS: [&str; 4] = ["/Program Files/Git/", "/Program Files (x86)/Git/", "/git/", "/msys64/"];
let rest_index = MSYS_ROOTS.iter().find_map(|root| normalized.find(root).map(|at| at + root.len()))?;
Some(&path[rest_index..])
}
fn has_drive_letter(path: &str) -> bool {
let mut chars = path.chars();
matches!((chars.next(), chars.next(), chars.next()), (Some(c), Some(':'), Some('/' | '\\')) if c.is_ascii_alphabetic())
}
#[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 host_specs() {
assert_eq!(parse_host("staging.example.com").unwrap(), ("staging.example.com".to_string(), 22));
assert_eq!(parse_host("10.0.0.1:2222").unwrap(), ("10.0.0.1".to_string(), 2222));
assert!(parse_host("").is_err());
assert!(parse_host("host:notaport").is_err());
}
#[test]
fn a_user_at_host_spec_explains_where_the_user_went() {
let err = parse_host("deploy@staging.example.com").unwrap_err().to_string();
assert!(err.contains("credential"), "{err}");
assert!(err.contains("--user deploy"), "the error must carry the name forward: {err}");
assert!(err.contains("staging.example.com"), "and the host to use instead: {err}");
}
#[test]
fn the_ssh_host_falls_back_to_the_url() {
assert_eq!(url_host("https://staging.example.com"), "staging.example.com");
assert_eq!(url_host("http://staging.example.com:8081/app"), "staging.example.com");
assert_eq!(url_host("https://user@host.example.com/x"), "host.example.com");
assert_eq!(url_host("http://[::1]:8080"), "[::1]");
assert_eq!(url_host("example.com"), "example.com");
}
fn a_target(name: &str) -> Target {
Target {
name: name.into(),
app: "web".into(),
server: "prod".into(),
credential: "deploy".into(),
path: "wwwroot".into(),
}
}
#[test]
fn a_generated_target_name_joins_the_parts() {
assert_eq!(target_name("web", "prod"), "web-prod");
assert_eq!(unique_target_name("web", "prod", &[]), "web-prod");
}
#[test]
fn a_colliding_target_name_gains_a_suffix() {
let taken = vec![a_target("web-prod")];
assert_eq!(unique_target_name("web", "prod", &taken), "web-prod-2");
let taken = vec![a_target("web-prod"), a_target("web-prod-2")];
assert_eq!(unique_target_name("web", "prod", &taken), "web-prod-3");
assert_eq!(unique_target_name("api", "prod", &taken), "api-prod");
}
#[test]
fn generated_names_are_valid_names() {
assert!(validate_name(&target_name("my-app", "kib-2")).is_ok());
assert!(validate_name(&unique_target_name("web", "prod", &[a_target("web-prod")])).is_ok());
}
#[test]
fn auth_kinds_round_trip() {
assert_eq!("password".parse::<Auth>().unwrap(), Auth::Password);
assert_eq!("key".parse::<Auth>().unwrap(), Auth::Key);
assert_eq!("agent".parse::<Auth>().unwrap(), Auth::Agent);
assert_eq!(Auth::Key.to_string(), "key");
assert_eq!(Auth::Agent.to_string(), "agent");
for auth in [Auth::Password, Auth::Key, Auth::Agent] {
assert_eq!(auth.as_str().parse::<Auth>().unwrap(), auth);
}
assert!("smartcard".parse::<Auth>().is_err());
}
#[test]
fn remote_paths_must_be_absolute() {
assert!(validate_remote_path("/var/www/myapp").is_ok());
assert!(validate_remote_path("/srv/app with spaces").is_ok());
assert!(validate_remote_path("").is_err());
assert!(validate_remote_path(" ").is_err());
assert!(validate_remote_path("var/www/myapp").is_err(), "relative paths are not a server directory");
assert!(validate_remote_path("inetpub\\myapp").is_err(), "relative is relative on Windows too");
}
#[test]
fn windows_server_directories_are_accepted() {
assert!(validate_remote_path("C:\\inetpub\\wwwroot\\myapp").is_ok());
assert!(validate_remote_path("D:/sites/myapp").is_ok());
assert!(validate_remote_path("C:\\sites\\my app").is_ok(), "spaces are ordinary in Windows paths");
assert!(
validate_remote_path("\\\\fileserver\\share\\myapp").is_ok(),
"a UNC share is a real destination"
);
}
#[test]
fn remote_paths_drop_the_escaping_slash() {
assert_eq!(normalize_remote_path("//var/www/myapp"), "/var/www/myapp");
assert_eq!(normalize_remote_path("/var/www/myapp"), "/var/www/myapp");
assert_eq!(normalize_remote_path(" /srv/app "), "/srv/app");
assert_eq!(normalize_remote_path("///odd"), "///odd");
assert_eq!(normalize_remote_path("\\\\fileserver\\share"), "\\\\fileserver\\share");
}
#[test]
fn a_git_bash_rewrite_is_caught_and_explained() {
let mangled = validate_remote_path("C:/Program Files/Git/var/www/myapp").unwrap_err().to_string();
assert!(mangled.contains("MSYS_NO_PATHCONV"), "the error must say how to get past it: {mangled}");
assert!(mangled.contains("var/www/myapp"), "it must name the path the user meant: {mangled}");
assert!(validate_remote_path("C:\\Program Files\\Git\\var\\www\\myapp").is_err(), "backslashes too");
assert!(validate_remote_path("C:/msys64/var/www/myapp").is_err());
}
#[test]
fn a_windows_path_that_mentions_git_is_not_a_rewrite() {
assert!(validate_remote_path("C:\\sites\\gitlab-runner").is_ok());
assert!(validate_remote_path("D:\\git-repos\\myapp").is_ok());
}
}