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, skip_serializing_if = "Option::is_none")]
pub dev_port: Option<u16>,
#[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.pad(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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health: Option<HealthCheck>,
}
impl Target {
pub fn address(&self, server: &Server) -> String {
self.url.clone().unwrap_or_else(|| server.url.clone())
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct HealthCheck {
#[serde(flatten)]
pub probe: Probe,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait: Option<u64>,
}
pub const DEFAULT_HEALTH_WAIT: u64 = 60;
impl HealthCheck {
pub fn wait(&self) -> std::time::Duration {
std::time::Duration::from_secs(self.wait.unwrap_or(DEFAULT_HEALTH_WAIT))
}
pub fn describe(&self, address: &str) -> String {
match &self.probe {
Probe::Url(url) => format!("GET {}", health_url(url, address)),
Probe::Command(command) => format!("`{command}`"),
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Probe {
Url(String),
Command(String),
}
pub fn health_url(url: &str, address: &str) -> String {
if url.starts_with("http://") || url.starts_with("https://") {
url.to_string()
} else {
format!("{}/{}", address.trim_end_matches('/'), url.trim_start_matches('/'))
}
}
pub fn validate_health_url(url: &str) -> Result<()> {
if url.starts_with('/') || url.starts_with("http://") || url.starts_with("https://") {
Ok(())
} else {
bail!("invalid health URL '{url}': give a path under the target's address (/health) or a full http(s):// URL")
}
}
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, Debug, PartialEq, Eq)]
pub struct State {
#[serde(default)]
pub bindings: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub previous: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last: Option<String>,
}
impl State {
pub fn bind(&mut self, app: &str, server: &str) {
match self.bindings.insert(app.to_string(), server.to_string()) {
Some(before) if before != server => {
self.previous.insert(app.to_string(), before);
}
_ => {}
}
}
pub fn forget_app(&mut self, app: &str) {
self.bindings.remove(app);
self.previous.remove(app);
self.forget_name(app);
}
pub fn forget_server(&mut self, server: &str) -> Vec<String> {
let unbound: Vec<String> = self.bindings.iter().filter(|(_, s)| *s == server).map(|(app, _)| app.clone()).collect();
self.bindings.retain(|_, s| s != server);
self.previous.retain(|_, s| s != server);
unbound
}
pub fn forget_name(&mut self, name: &str) {
if self.last.as_deref() == Some(name) {
self.last = None;
}
}
}
#[derive(Clone)]
pub struct Gateway {
pub pid: u32,
pub started: u64,
pub ports: BTreeMap<u16, String>,
pub front_port: Option<u16>,
pub control: Option<crate::registry::Control>,
pub signals: Vec<crate::signal::Signal>,
}
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(),
url: None,
health: None,
}
}
#[test]
fn a_health_check_is_stored_as_its_one_probe() {
let mut target = a_target("web-prod");
assert!(!serde_json::to_string(&target).unwrap().contains("health"), "nothing to write");
target.health = Some(HealthCheck {
probe: Probe::Url("/health".into()),
wait: None,
});
let json = serde_json::to_value(&target).unwrap();
assert_eq!(json["health"], serde_json::json!({"url": "/health"}));
let back: Target = serde_json::from_value(json).unwrap();
assert_eq!(back.health, target.health);
let command: HealthCheck = serde_json::from_str(r#"{"command":"curl -f localhost:3000","wait":120}"#).unwrap();
assert_eq!(command.probe, Probe::Command("curl -f localhost:3000".into()));
assert_eq!(command.wait(), std::time::Duration::from_secs(120));
assert!(
serde_json::from_str::<HealthCheck>(r#"{"wait":5}"#).is_err(),
"a check with no probe is not a check"
);
}
#[test]
fn a_health_path_joins_the_address() {
assert_eq!(health_url("/health", "https://admin.example.com/"), "https://admin.example.com/health");
assert_eq!(health_url("/api/health", "https://example.com/app"), "https://example.com/app/api/health");
assert_eq!(health_url("http://10.0.0.5:3000/up", "https://example.com"), "http://10.0.0.5:3000/up");
assert!(validate_health_url("/health").is_ok());
assert!(validate_health_url("https://x.example.com/up").is_ok());
assert!(validate_health_url("health").is_err(), "a bare word joins into nonsense");
let mut target = a_target("web-prod");
let server = Server {
name: "prod".into(),
label: None,
url: "https://api.example.com".into(),
host: None,
port: 22,
accept_invalid_certs: false,
shell: None,
credential: None,
};
assert_eq!(target.address(&server), "https://api.example.com", "the server's, when it names none");
target.url = Some("https://admin.example.com".into());
assert_eq!(target.address(&server), "https://admin.example.com");
}
#[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 binding_remembers_the_server_before() {
let mut state = State::default();
state.bind("web", "dev");
assert!(state.previous.is_empty(), "the first binding has nothing before it");
state.bind("web", "prod");
assert_eq!(state.previous.get("web").map(String::as_str), Some("dev"));
state.bind("web", "prod");
assert_eq!(
state.previous.get("web").map(String::as_str),
Some("dev"),
"the same server again is not a switch"
);
}
#[test]
fn removed_entities_leave_the_state() {
let mut state = State::default();
state.bind("web", "dev");
state.bind("web", "prod");
state.bind("api", "prod");
state.bind("admin", "dev");
state.last = Some("web".into());
assert_eq!(state.forget_server("prod"), vec!["api".to_string(), "web".to_string()]);
assert_eq!(state.bindings.get("admin").map(String::as_str), Some("dev"));
assert!(!state.bindings.contains_key("web"));
assert_eq!(
state.previous.get("web").map(String::as_str),
Some("dev"),
"a previous server that exists stays"
);
state.forget_server("dev");
assert!(state.previous.is_empty());
state.bind("web", "stage");
state.forget_app("web");
assert_eq!(state, State::default());
}
#[test]
fn an_older_state_file_still_reads() {
let state: State = serde_json::from_str(r#"{"bindings":{"web":"prod"}}"#).unwrap();
assert_eq!(state.bindings.get("web").map(String::as_str), Some("prod"));
assert!(state.previous.is_empty() && state.last.is_none());
assert_eq!(serde_json::to_string(&state).unwrap(), r#"{"bindings":{"web":"prod"}}"#);
}
#[test]
fn auth_kinds_fill_their_column() {
assert_eq!(format!("{:8}|", Auth::Key), "key |");
}
#[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());
}
}