use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Result, bail};
use crate::model::{App, Credential, Server};
use crate::shell::{self, Dialect};
use crate::ssh::Session;
use crate::store;
pub struct Resolved {
pub app: App,
pub server: Server,
pub credential: Credential,
pub path: crate::model::Path,
pub target: Option<String>,
}
impl Resolved {
pub fn connection_label(&self) -> String {
format!("{}@{}:{}", self.credential.user, self.server.ssh_host(), self.server.port)
}
}
#[derive(Default)]
pub struct Overrides {
pub server: Option<String>,
pub credential: Option<String>,
pub path: Option<String>,
}
impl Overrides {
fn any(&self) -> bool {
self.server.is_some() || self.credential.is_some() || self.path.is_some()
}
}
pub fn resolve(name: Option<String>, overrides: Overrides) -> Result<Resolved> {
let targets = store::load_targets()?;
if let Some(name) = &name
&& let Some(target) = targets.iter().find(|t| &t.name == name)
{
return from_target(target.clone(), overrides);
}
let apps = store::load_apps()?;
let app = crate::commands::exec::resolve(&apps, name)?.clone();
let server_name = match &overrides.server {
Some(name) => name.clone(),
None => store::load_state()?.bindings.get(&app.name).cloned().ok_or_else(|| {
anyhow::anyhow!(
"nowhere to deploy '{0}': name a target (see `turnout target list`), pass --server, \
or bind one with `turnout use {0} SERVER`",
app.name
)
})?,
};
let mut matching = targets.iter().filter(|t| t.app == app.name && t.server == server_name);
let found = match (matching.next(), matching.next()) {
(Some(only), None) => Some(only.clone()),
(Some(first), Some(second)) => bail!(
"'{}' has more than one target on '{server_name}' ('{}', '{}') - name the one to deploy",
app.name,
first.name,
second.name
),
(None, _) => None,
};
match found {
Some(target) => from_target(target, overrides),
None => assemble(app, server_name, overrides),
}
}
fn from_target(target: crate::model::Target, overrides: Overrides) -> Result<Resolved> {
let overridden = overrides.any();
let app = store::load_apps()?
.into_iter()
.find(|a| a.name == target.app)
.ok_or_else(|| dangling(&target.name, "app", &target.app, "turnout app list"))?;
let server_name = overrides.server.unwrap_or_else(|| target.server.clone());
let server = store::load_servers()?
.into_iter()
.find(|s| s.name == server_name)
.ok_or_else(|| dangling(&target.name, "server", &server_name, "turnout server list"))?;
check_allowed(&app, &server.name)?;
let credential_name = overrides.credential.unwrap_or_else(|| target.credential.clone());
let credential = store::load_credentials()?
.into_iter()
.find(|c| c.name == credential_name)
.ok_or_else(|| dangling(&target.name, "credential", &credential_name, "turnout credential list"))?;
let path_name = overrides.path.unwrap_or_else(|| target.path.clone());
let path = store::load_paths()?
.into_iter()
.find(|p| p.name == path_name)
.ok_or_else(|| dangling(&target.name, "path", &path_name, "turnout path list"))?;
Ok(Resolved {
app,
server,
credential,
path,
target: (!overridden).then_some(target.name),
})
}
fn dangling(target: &str, kind: &str, name: &str, list: &str) -> anyhow::Error {
anyhow::anyhow!("target '{target}' names {kind} '{name}', which is not in the catalog - see `{list}`")
}
fn assemble(app: App, server_name: String, overrides: Overrides) -> Result<Resolved> {
let server = store::load_servers()?
.into_iter()
.find(|s| s.name == server_name)
.ok_or_else(|| anyhow::anyhow!("no server named '{server_name}' - see `turnout server list`"))?;
check_allowed(&app, &server.name)?;
let credential_name = overrides.credential.or_else(|| server.credential.clone()).ok_or_else(|| {
anyhow::anyhow!(
"server '{0}' has no credential - set one with `turnout server edit {0} --credential NAME`, \
or pass --credential for this command only",
server.name
)
})?;
let credential = store::load_credentials()?
.into_iter()
.find(|c| c.name == credential_name)
.ok_or_else(|| anyhow::anyhow!("no credential named '{credential_name}' - see `turnout credential list`"))?;
let paths = store::load_paths()?;
let path_name = match overrides.path {
Some(name) => name,
None => {
crate::pick::ensure_interactive(&format!(
"no target for '{}' on '{}': create one with `turnout target add`, or pass --path",
app.name, server.name
))?;
eprintln!("No target for '{}' on '{}' yet.", app.name, server.name);
crate::pick::path(&paths, "Deploy into which path")?
}
};
let path = paths
.into_iter()
.find(|p| p.name == path_name)
.ok_or_else(|| anyhow::anyhow!("no path named '{path_name}' - see `turnout path list`"))?;
Ok(Resolved {
app,
server,
credential,
path,
target: None,
})
}
fn check_allowed(app: &App, server: &str) -> Result<()> {
if !app.servers.is_empty() && !app.servers.iter().any(|s| s == server) {
bail!("server '{server}' is not allowed for '{}' (allowed: {})", app.name, app.servers.join(", "));
}
Ok(())
}
pub fn connect(server: &Server, credential: &Credential) -> Result<Session> {
Session::connect(server, credential)
}
pub fn exec(session: &Session, command: &str) -> Result<String> {
session.exec(command)
}
pub fn dialect(session: &Session, server: &Server) -> Dialect {
if let Some(known) = server.shell {
return known;
}
let Ok(reply) = exec(session, shell::PROBE) else {
return Dialect::default();
};
let learned = shell::read_probe(&reply);
remember_dialect(&server.name, learned);
learned
}
fn remember_dialect(server_name: &str, dialect: Dialect) {
let Ok(mut servers) = store::load_servers() else {
return;
};
let Some(entry) = servers.iter_mut().find(|s| s.name == server_name) else {
return;
};
entry.shell = Some(dialect);
let _ = store::save_servers(&servers);
}
pub fn check_quotable(dialect: Dialect, values: &[&str]) -> Result<()> {
for value in values {
if let Err(reason) = dialect.reject_unquotable(value) {
bail!("cannot run this on a Windows server: {reason}");
}
}
Ok(())
}
pub fn backups_dir(deploy_path: &str) -> String {
format!("{}.backups", deploy_path.trim_end_matches(['/', '\\']))
}
pub fn join_remote(dir: &str, name: &str) -> String {
let separator = if dir.contains('\\') && !dir.contains('/') { '\\' } else { '/' };
format!("{}{separator}{name}", dir.trim_end_matches(['/', '\\']))
}
pub fn backup_name() -> String {
let secs = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let (year, month, day) = crate::utils::civil_from_days((secs / 86_400) as i64);
let rest = secs % 86_400;
format!("{year:04}{month:02}{day:02}-{:02}{:02}{:02}.tar.gz", rest / 3600, (rest % 3600) / 60, rest % 60)
}
pub fn backup_command(dialect: Dialect, deploy_path: &str, archive_name: &str) -> String {
let dir = deploy_path.trim_end_matches(['/', '\\']);
let backups = backups_dir(deploy_path);
let archive = join_remote(&backups, archive_name);
dialect.and_then(&dialect.mkdir_p(&backups), &dialect.tar_czf(&archive, dir))
}
pub fn run_backup(session: &Session, dialect: Dialect, deploy_path: &str) -> Result<String> {
let name = backup_name();
check_quotable(dialect, &[deploy_path, &backups_dir(deploy_path)])?;
exec(session, &backup_command(dialect, deploy_path, &name)).map(|_| name).map_err(|err| {
if mentions_permission_denial(&err) {
anyhow::anyhow!(permission_hint(dialect, deploy_path))
} else {
err
}
})
}
fn permission_hint(dialect: Dialect, deploy_path: &str) -> String {
let backups = backups_dir(deploy_path);
let parent = parent_dir(&backups);
let fix = match dialect {
Dialect::Posix => format!("sudo mkdir -p {backups} && sudo chown $USER {backups}"),
Dialect::Windows => format!("mkdir \"{backups}\" (in an elevated prompt, if {parent} needs it)"),
};
format!(
"cannot write backups to {backups}: permission denied.\n\
Backups are kept next to the deploy directory, so this needs write access to {parent} - \
being able to upload into the deploy directory itself is not enough.\n\
Create it once on the server with the right owner, e.g.\n {fix}"
)
}
fn mentions_permission_denial(err: &anyhow::Error) -> bool {
let text = format!("{err:#}").to_lowercase();
text.contains("permission denied") || text.contains("cannot create directory")
}
fn parent_dir(path: &str) -> &str {
match path.trim_end_matches(['/', '\\']).rsplit_once(['/', '\\']) {
Some(("", _)) | None => "/",
Some((parent, _)) => parent,
}
}
#[cfg(test)]
mod tests {
use super::{backup_command, backup_name, backups_dir, join_remote, mentions_permission_denial, parent_dir};
use crate::shell::Dialect;
#[test]
fn backups_sit_beside_the_deploy_directory() {
assert_eq!(backups_dir("/var/www/myapp"), "/var/www/myapp.backups");
assert_eq!(backups_dir("/var/www/myapp/"), "/var/www/myapp.backups");
}
#[test]
fn backups_follow_the_separator_of_the_path() {
assert_eq!(backups_dir("C:\\inetpub\\site"), "C:\\inetpub\\site.backups");
assert_eq!(backups_dir("C:\\inetpub\\site\\"), "C:\\inetpub\\site.backups");
assert_eq!(join_remote("C:\\site.backups", "a.tar.gz"), "C:\\site.backups\\a.tar.gz");
assert_eq!(join_remote("/var/www/site.backups", "a.tar.gz"), "/var/www/site.backups/a.tar.gz");
}
#[test]
fn parent_of_a_backups_dir() {
assert_eq!(parent_dir("/var/www/myapp.backups"), "/var/www");
assert_eq!(parent_dir("/srv"), "/");
assert_eq!(parent_dir("/"), "/");
assert_eq!(parent_dir("C:\\inetpub\\site.backups"), "C:\\inetpub");
}
#[test]
fn recognizes_permission_failures_only() {
let denied = anyhow::anyhow!("remote command 'mkdir -p /var/www/x.backups' exited with 1: mkdir: cannot create directory: Permission denied");
assert!(mentions_permission_denial(&denied));
let other = anyhow::anyhow!("remote command 'tar czf ...' exited with 2: tar: not found");
assert!(!mentions_permission_denial(&other), "unrelated failures must keep their own message");
}
#[test]
fn permission_hint_names_the_parent_and_the_fix() {
let hint = super::permission_hint(Dialect::Posix, "/var/www/myapp");
assert!(hint.contains("/var/www/myapp.backups"), "{hint}");
assert!(hint.contains("write access to /var/www"), "{hint}");
assert!(hint.contains("sudo mkdir -p /var/www/myapp.backups"), "{hint}");
let windows = super::permission_hint(Dialect::Windows, "C:\\inetpub\\site");
assert!(windows.contains("mkdir \"C:\\inetpub\\site.backups\""), "{windows}");
assert!(!windows.contains("sudo"), "sudo means nothing on Windows: {windows}");
}
#[test]
fn the_backup_command_carries_no_posix_only_syntax() {
let posix = backup_command(Dialect::Posix, "/var/www/site", "20260812-181500.tar.gz");
assert_eq!(
posix,
"mkdir -p '/var/www/site.backups' && tar czf '/var/www/site.backups/20260812-181500.tar.gz' -C '/var/www/site' ."
);
let windows = backup_command(Dialect::Windows, "C:\\inetpub\\site", "20260812-181500.tar.gz");
assert!(!windows.contains("$("), "command substitution does not exist in cmd.exe: {windows}");
assert!(!windows.contains("mkdir -p"), "cmd.exe mkdir has no -p: {windows}");
assert!(windows.contains("if not exist"), "{windows}");
assert!(
windows.contains("tar czf \"C:\\inetpub\\site.backups\\20260812-181500.tar.gz\" -C \"C:\\inetpub\\site\" ."),
"{windows}"
);
}
#[test]
fn backup_names_sort_chronologically() {
let name = backup_name();
assert!(name.ends_with(".tar.gz"), "{name}");
let stem = name.trim_end_matches(".tar.gz");
assert_eq!(stem.len(), 15, "YYYYMMDD-HHMMSS: {name}");
assert_eq!(stem.as_bytes()[8], b'-', "{name}");
assert!(stem.chars().filter(|c| *c != '-').all(|c| c.is_ascii_digit()), "{name}");
assert!("20260812-000000" < "20260812-181500", "the format has to order lexicographically");
}
}