use std::collections::BTreeMap;
use std::net::ToSocketAddrs;
use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::{Result, anyhow};
use crate::commands::doctor::CheckView;
use crate::health::Health;
use crate::model::{App, Auth, Credential, Server};
use crate::shell::Dialect;
use crate::ssh::Session;
use crate::{pick, progress, remote, store};
const SERVER: &str = "server";
const PATHS: &str = "paths";
const WEB: &str = "web";
const REACH_TIMEOUT: Duration = Duration::from_secs(5);
const LOW_SPACE: u64 = 1024 * 1024 * 1024;
const STEPS: [(&str, &[&str]); 4] = [
("start", &["artifacts", "credential"]),
("connect", &["sshd", "sign_in"]),
("upload", &["writable", "disk"]),
("restart", &["restart"]),
];
pub fn resolve(name: Option<String>) -> Result<Server> {
let servers = store::load_servers()?;
let name = match name {
Some(name) => name,
None => {
pick::ensure_interactive("name the server: turnout doctor --server NAME")?;
pick::server(&servers, "Server to check")?
}
};
servers
.into_iter()
.find(|server| server.name == name)
.ok_or_else(|| anyhow!("no server named '{name}' - see `turnout server list`"))
}
pub fn stops_at(checks: &[CheckView]) -> Option<&'static str> {
let failed: Vec<&str> = checks
.iter()
.filter(|check| check.state == crate::commands::doctor::State::Fail)
.map(|check| check.check)
.collect();
STEPS
.iter()
.find(|(_, guarded)| guarded.iter().any(|check| failed.contains(check)))
.map(|(step, _)| *step)
}
struct Login {
credential: Credential,
paths: BTreeMap<String, (crate::model::Path, Option<App>)>,
}
pub fn checks(server: &Server) -> Result<Vec<CheckView>> {
let name = Some(server.name.as_str());
let apps = store::load_apps()?;
let credentials = store::load_credentials()?;
let paths = store::load_paths()?;
let mut checks = Vec::new();
let logins = logins(server, &apps, &credentials, &paths, &mut checks);
let web = std::thread::scope(|scope| {
let web = scope.spawn(|| crate::health::probe_one(server));
let host = server.ssh_host();
match reach(&host, server.port) {
Ok(took) => {
checks.push(CheckView::ok(
SERVER,
"sshd",
name,
format!("sshd answers on {host}:{} in {} ms", server.port, took.as_millis()),
));
for login in &logins {
checks.extend(through(server, login));
}
}
Err(why) => checks.push(
CheckView::fail(SERVER, "sshd", name, why).fix("start sshd there, or check the host and port: turnout server edit NAME --host HOST:PORT"),
),
}
web.join().unwrap_or(Health::Unreachable)
});
checks.push(web_check(server, web));
Ok(checks)
}
fn logins(server: &Server, apps: &[App], credentials: &[Credential], paths: &[crate::model::Path], checks: &mut Vec<CheckView>) -> Vec<Login> {
let mut logins: BTreeMap<String, Login> = BTreeMap::new();
let find = |name: &str| credentials.iter().find(|c| c.name == name).cloned();
for target in store::load_targets().unwrap_or_default().into_iter().filter(|t| t.server == server.name) {
let Some(credential) = find(&target.credential) else {
checks.push(
CheckView::fail(
SERVER,
"credential",
Some(&target.name),
format!("signs in as '{}', which is not in the catalog", target.credential),
)
.fix(format!("turnout target edit {} --credential NAME", target.name)),
);
continue;
};
let Some(path) = paths.iter().find(|p| p.name == target.path).cloned() else {
checks.push(
CheckView::fail(
SERVER,
"credential",
Some(&target.name),
format!("writes to path '{}', which is not in the catalog", target.path),
)
.fix(format!("turnout target edit {} --path NAME", target.name)),
);
continue;
};
let app = apps.iter().find(|a| a.name == target.app).cloned();
if let Some(app) = &app
&& app.dist_dir.is_none()
{
checks.push(
CheckView::fail(
SERVER,
"artifacts",
Some(&target.name),
format!("'{}' names no build directory - a deploy has nothing to send", app.name),
)
.fix(format!("turnout app edit {} --dist DIR", app.name)),
);
}
logins
.entry(credential.name.clone())
.or_insert_with(|| Login {
credential,
paths: BTreeMap::new(),
})
.paths
.insert(path.name.clone(), (path, app));
}
if logins.is_empty() {
match server.credential.as_deref().map(|name| (name, find(name))) {
Some((_, Some(credential))) => {
logins.insert(
credential.name.clone(),
Login {
credential,
paths: BTreeMap::new(),
},
);
}
Some((missing, None)) => checks.push(
CheckView::fail(
SERVER,
"credential",
Some(&server.name),
format!("logs in as '{missing}', which is not in the catalog"),
)
.fix(format!("turnout server edit {} --credential NAME", server.name)),
),
None => checks.push(
CheckView::fail(
SERVER,
"credential",
Some(&server.name),
"has no credential and no target - there is nobody to sign in as",
)
.fix(format!("turnout server edit {} --credential NAME, or turnout deploy-setup", server.name)),
),
}
}
logins.into_values().collect()
}
fn reach(host: &str, port: u16) -> std::result::Result<Duration, String> {
let addresses: Vec<_> = (host, port)
.to_socket_addrs()
.map_err(|_| format!("{host} does not resolve - the name is wrong, or the VPN is off"))?
.collect();
let started = Instant::now();
for address in &addresses {
if std::net::TcpStream::connect_timeout(address, REACH_TIMEOUT).is_ok() {
return Ok(started.elapsed());
}
}
Err(format!(
"nothing answers on {host}:{port} within {}s - sshd is down, or a firewall is in the way",
REACH_TIMEOUT.as_secs()
))
}
fn through(server: &Server, login: &Login) -> Vec<CheckView> {
let credential = &login.credential;
let who = Some(credential.name.as_str());
let mut checks = Vec::new();
let session = match Session::connect(server, credential) {
Ok(session) => session,
Err(err) => {
let fix = match credential.auth {
Auth::Password => format!("turnout pass set {} - or set up a key: turnout key setup {}", credential.name, server.name),
Auth::Key => format!("turnout key setup {} --credential {}", server.name, credential.name),
Auth::Agent => "add the key to the agent: ssh-add".to_string(),
};
checks.push(CheckView::fail(SERVER, "sign_in", who, format!("{}@ cannot sign in: {err:#}", credential.user)).fix(fix));
return checks;
}
};
let dialect = remote::dialect(&session, server);
let how = match credential.auth {
Auth::Password => "password".to_string(),
Auth::Key => format!("key {}", credential.key.as_deref().unwrap_or_default()),
Auth::Agent => "the SSH agent".to_string(),
};
let shell = match dialect {
Dialect::Posix => "sh",
Dialect::Windows => "cmd.exe",
};
checks.push(CheckView::ok(
SERVER,
"sign_in",
who,
format!("{}@ signs in with {how} · shell: {shell}", credential.user),
));
match remote::exec(&session, "tar --version") {
Ok(version) => checks.push(CheckView::ok(
SERVER,
"tar",
who,
format!("tar · {}", version.lines().next().unwrap_or_default().trim()),
)),
Err(_) => checks.push(CheckView::warn(
SERVER,
"tar",
who,
"no tar answers - a deploy sends the files one by one, which is slower",
)),
}
for (path, app) in login.paths.values() {
checks.extend(path_checks(&session, dialect, &credential.user, path, app.as_ref()));
}
checks
}
#[derive(Debug, Default, PartialEq, Eq)]
struct Probed {
dir: String,
exists: bool,
writable: bool,
owner: Option<String>,
free_kib: Option<u64>,
user: Option<String>,
}
fn read_probe(output: &str) -> Probed {
let mut probed = Probed::default();
for line in output.lines() {
let Some((key, value)) = line.trim().split_once('=') else { continue };
let value = value.trim();
match key {
"dir" => probed.dir = value.to_string(),
"exists" => probed.exists = value == "yes",
"writable" => probed.writable = value == "yes",
"owner" if !value.is_empty() => probed.owner = Some(value.to_string()),
"free" => probed.free_kib = value.parse().ok(),
"user" if !value.is_empty() => probed.user = Some(value.to_string()),
_ => {}
}
}
probed
}
fn probe_command(dialect: Dialect, dir: &str) -> String {
match dialect {
Dialect::Posix => {
let target = dialect.quote(dir);
let script = format!(
"d={target}; while [ ! -d \"$d\" ]; do p=$(dirname \"$d\"); [ \"$p\" = \"$d\" ] && break; d=$p; done; \
echo \"dir=$d\"; if [ -d {target} ]; then echo exists=yes; else echo exists=no; fi; \
f=\"$d/.turnout-doctor-$$\"; if ( : > \"$f\" ) 2>/dev/null; then rm -f \"$f\"; echo writable=yes; else echo writable=no; fi; \
echo \"owner=$(ls -ld \"$d\" | awk '{{print $3}}')\"; \
echo \"free=$(df -Pk \"$d\" | awk 'NR==2 {{print $4}}')\"; \
echo \"user=$(id -un)\""
);
format!("sh -c {}", dialect.quote(&script))
}
Dialect::Windows => {
let target = dir.replace('\'', "''");
let script = format!(
"$ErrorActionPreference = 'SilentlyContinue'\n\
$target = '{target}'\n\
$d = $target\n\
while ($d -and -not (Test-Path -LiteralPath $d -PathType Container)) {{ $d = Split-Path -Parent $d }}\n\
'dir=' + $d\n\
if (Test-Path -LiteralPath $target -PathType Container) {{ 'exists=yes' }} else {{ 'exists=no' }}\n\
$f = Join-Path $d ('.turnout-doctor-' + $PID)\n\
try {{ [IO.File]::WriteAllText($f, ''); Remove-Item -LiteralPath $f -Force; 'writable=yes' }} catch {{ 'writable=no' }}\n\
try {{ 'owner=' + (Get-Acl -LiteralPath $d).Owner }} catch {{ }}\n\
try {{ 'free=' + [math]::Floor((New-Object IO.DriveInfo ([IO.Path]::GetPathRoot($d))).AvailableFreeSpace / 1024) }} catch {{ }}\n\
'user=' + [Environment]::UserName\n"
);
crate::shell::powershell(&script)
}
}
}
fn path_checks(session: &Session, dialect: Dialect, user: &str, path: &crate::model::Path, app: Option<&App>) -> Vec<CheckView> {
let subject = Some(path.name.as_str());
let mut checks = Vec::new();
if let Err(reason) = dialect.reject_unquotable(&path.dir) {
checks.push(CheckView::fail(PATHS, "writable", subject, reason).fix(format!("turnout path edit {} --dir DIR", path.name)));
return checks;
}
let probed = match remote::exec(session, &probe_command(dialect, &path.dir)) {
Ok(output) => read_probe(&output),
Err(err) => {
checks.push(CheckView::warn(
PATHS,
"writable",
subject,
format!("{} could not be asked about: {err:#}", path.dir),
));
return checks;
}
};
let user = probed.user.clone().unwrap_or_else(|| user.to_string());
let owner = probed.owner.as_deref().map(|owner| format!(", owned by {owner}")).unwrap_or_default();
checks.push(match (probed.exists, probed.writable) {
(true, true) => CheckView::ok(PATHS, "writable", subject, format!("{} is writable for {user}", path.dir)),
(false, true) => CheckView::ok(
PATHS,
"writable",
subject,
format!("{} is not there yet - a deploy creates it under {}, writable for {user}", path.dir, probed.dir),
),
(true, false) => {
CheckView::fail(PATHS, "writable", subject, format!("{} is not writable for {user}{owner}", path.dir)).fix(grant(dialect, &path.dir, &user))
}
(false, false) => CheckView::fail(
PATHS,
"writable",
subject,
format!(
"{} is not there, and {} is not writable for {user}{owner} - a deploy cannot create it",
path.dir, probed.dir
),
)
.fix(create(dialect, &path.dir, &user)),
});
checks.push(disk_check(path, &probed, app));
if let Some(restart) = &path.restart {
checks.push(restart_check(session, dialect, path, restart, &user, probed.exists));
}
checks
}
fn grant(dialect: Dialect, dir: &str, user: &str) -> String {
match dialect {
Dialect::Posix => format!("sudo chown -R {user} {}", dialect.quote(dir)),
Dialect::Windows => format!("icacls \"{dir}\" /grant {user}:(OI)(CI)M"),
}
}
fn create(dialect: Dialect, dir: &str, user: &str) -> String {
match dialect {
Dialect::Posix => format!("sudo mkdir -p {0} && sudo chown {user} {0}", dialect.quote(dir)),
Dialect::Windows => format!("mkdir \"{dir}\" && icacls \"{dir}\" /grant {user}:(OI)(CI)M"),
}
}
fn disk_check(path: &crate::model::Path, probed: &Probed, app: Option<&App>) -> CheckView {
let subject = Some(path.name.as_str());
let Some(free_kib) = probed.free_kib else {
return CheckView::warn(PATHS, "disk", subject, format!("the free space at {} could not be read", probed.dir));
};
let free = free_kib.saturating_mul(1024);
let built = app.and_then(|app| Some((app, app.dist_dir.as_deref()?))).and_then(|(app, dist)| {
let dir = Path::new(&app.path).join(dist);
dir.is_dir().then(|| size_of_dir(&dir))
});
let need = built.map(|size| size.saturating_mul(2));
let last = built
.map(|size| format!(" (the last build is {})", progress::human_bytes(size)))
.unwrap_or_default();
match need {
Some(need) if free < need => CheckView::fail(
PATHS,
"disk",
subject,
format!(
"{} free at {} - a deploy needs about {}{last}",
progress::human_bytes(free),
probed.dir,
progress::human_bytes(need)
),
)
.fix("free space on the server: old backups sit beside the deploy directory, in .backups"),
_ if free < LOW_SPACE => CheckView::warn(
PATHS,
"disk",
subject,
format!("only {} free at {}{last}", progress::human_bytes(free), probed.dir),
)
.fix("free space on the server: old backups sit beside the deploy directory, in .backups"),
_ => CheckView::ok(PATHS, "disk", subject, format!("{} free{last}", progress::human_bytes(free))),
}
}
fn size_of_dir(dir: &Path) -> u64 {
let mut total = 0;
let mut pending = vec![dir.to_path_buf()];
while let Some(dir) = pending.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for entry in entries.flatten() {
match entry.file_type() {
Ok(kind) if kind.is_dir() => pending.push(entry.path()),
Ok(_) => total += entry.metadata().map(|meta| meta.len()).unwrap_or(0),
Err(_) => {}
}
}
}
total
}
const CMD_BUILTINS: [&str; 22] = [
"call", "cd", "chdir", "cls", "copy", "del", "dir", "echo", "erase", "exit", "for", "if", "md", "mkdir", "move", "popd", "pushd", "rd", "ren", "rmdir",
"set", "start",
];
fn restart_check(session: &Session, dialect: Dialect, path: &crate::model::Path, restart: &str, user: &str, dir_exists: bool) -> CheckView {
let subject = Some(path.name.as_str());
let (sudo, programs) = restart_programs(dialect, restart);
if sudo && dialect == Dialect::Posix && remote::exec(session, "sudo -n true").is_err() {
return CheckView::fail(
PATHS,
"restart",
subject,
format!("restart `{restart}` - sudo asks {user} for a password, and a deploy has nobody to type it"),
)
.fix(format!("allow the command without a password for {user}: a NOPASSWD rule in sudoers"));
}
if programs.is_empty() {
return CheckView::ok(PATHS, "restart", subject, format!("restart `{restart}` - shell built-ins only"));
}
if !dir_exists {
return CheckView::info(
PATHS,
"restart",
subject,
format!(
"restart `{restart}` runs in {}, which a deploy creates first - asked after the first deploy",
path.dir
),
);
}
let missing: Vec<&str> = programs
.iter()
.filter(|program| {
let lookup = match dialect {
Dialect::Posix => format!("sh -c {}", dialect.quote(&format!("command -v {}", dialect.quote(program)))),
Dialect::Windows => format!("where {}", dialect.quote(program)),
};
remote::exec(session, &dialect.run_in(&path.dir, &lookup)).is_err()
})
.map(String::as_str)
.collect();
if missing.is_empty() {
let there = if programs.len() == 1 { "is there" } else { "are there" };
return CheckView::ok(PATHS, "restart", subject, format!("restart `{restart}` - {} {there}", programs.join(", ")));
}
CheckView::fail(
PATHS,
"restart",
subject,
format!(
"restart `{restart}` - {} {} not found for {user}",
missing.join(", "),
if missing.len() == 1 { "is" } else { "are" }
),
)
.fix(format!("install it there, or turnout path edit {} --restart CMD", path.name))
}
const POSIX_BUILTINS: [&str; 14] = [
"cd", "echo", "export", "set", "unset", "true", "false", "exit", "test", "[", ":", ".", "source", "eval",
];
fn restart_programs(dialect: Dialect, command: &str) -> (bool, Vec<String>) {
let mut sudo = false;
let mut programs: Vec<String> = Vec::new();
for part in commands_of(command) {
let Some((uses_sudo, program)) = restart_program(&part) else { continue };
sudo |= uses_sudo;
let builtin = match dialect {
Dialect::Posix => POSIX_BUILTINS.contains(&program.as_str()),
Dialect::Windows => CMD_BUILTINS.contains(&program.to_ascii_lowercase().as_str()),
};
if !builtin && !programs.contains(&program) {
programs.push(program);
}
}
(sudo, programs)
}
fn commands_of(line: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match (quote, c) {
(Some(open), c) if c == open => {
quote = None;
current.push(c);
}
(Some(_), c) => current.push(c),
(None, '\'' | '"') => {
quote = Some(c);
current.push(c);
}
(None, ';' | '|' | '&') => {
if chars.peek() == Some(&c) {
chars.next();
}
parts.push(std::mem::take(&mut current));
}
(None, c) => current.push(c),
}
}
parts.push(current);
parts.into_iter().map(|part| part.trim().to_string()).filter(|part| !part.is_empty()).collect()
}
fn restart_program(command: &str) -> Option<(bool, String)> {
const TAKES: [&str; 8] = ["-u", "-g", "-C", "-h", "-p", "-r", "-t", "-U"];
let mut words = command.split_whitespace();
let mut sudo = false;
let mut word = words.next()?;
loop {
if word == "sudo" && !sudo {
sudo = true;
word = words.next()?;
while word.starts_with('-') {
let takes = TAKES.contains(&word);
word = words.next()?;
if takes {
word = words.next()?;
}
}
continue;
}
if word.contains('=') && !word.starts_with('-') {
word = words.next()?;
continue;
}
break;
}
Some((sudo, word.trim_matches(['"', '\'']).to_string()))
}
fn web_check(server: &Server, health: Health) -> CheckView {
let name = Some(server.name.as_str());
let url = crate::health::health_url(&server.url);
match health {
Health::Up { status, millis } => CheckView::ok(WEB, "web", name, format!("the web server answers · {url} {status} in {millis} ms")),
Health::Down { status } => CheckView::fail(
WEB,
"web",
name,
format!("the web server answers {status} at {url} - the application behind it is down"),
),
Health::Unreachable => CheckView::fail(
WEB,
"web",
name,
format!("the web server does not answer at {url} - it is off, or out of reach"),
),
Health::Untrusted => CheckView::warn(
WEB,
"web",
name,
format!("the web server at {url} answers with a certificate this machine does not trust"),
)
.fix(format!("turnout server edit {} --insecure", server.name)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::doctor::State;
#[test]
fn the_probe_reads_back_what_the_script_prints() {
let probed = read_probe("dir=/var/www\nexists=no\nwritable=no\nowner=root\nfree=1048576\nuser=deploy\n");
assert_eq!(
probed,
Probed {
dir: "/var/www".into(),
exists: false,
writable: false,
owner: Some("root".into()),
free_kib: Some(1_048_576),
user: Some("deploy".into()),
}
);
let windows = read_probe("dir=C:\\inetpub\\api\r\nexists=yes\r\nwritable=yes\r\nfree=35651584\r\nuser=deploy\r\n");
assert!(windows.exists && windows.writable);
assert_eq!(windows.owner, None);
assert_eq!(windows.free_kib, Some(35_651_584));
}
#[test]
fn the_posix_probe_runs_under_sh_with_the_directory_quoted() {
let command = probe_command(Dialect::Posix, "/srv/my app");
assert!(command.starts_with("sh -c '"), "{command}");
assert!(command.contains("/srv/my app"), "{command}");
let windows = probe_command(Dialect::Windows, "C:\\it's");
assert!(windows.starts_with("powershell -NoProfile -NonInteractive -EncodedCommand "), "{windows}");
assert!(!windows.contains('\''), "the script travels encoded: {windows}");
}
#[test]
fn every_program_of_a_chained_restart_is_looked_for() {
assert_eq!(
restart_programs(Dialect::Posix, "cd /srv/app && docker compose pull && docker compose up -d"),
(false, vec!["docker".to_string()])
);
assert_eq!(
restart_programs(Dialect::Posix, "pm2 reload api || sudo systemctl restart api; echo done | tee log"),
(true, vec!["pm2".to_string(), "systemctl".to_string(), "tee".to_string()])
);
assert_eq!(
restart_programs(Dialect::Posix, "sh -c 'a && b' && node x.js"),
(false, vec!["sh".to_string(), "node".to_string()])
);
assert_eq!(
restart_programs(Dialect::Windows, "cd /d C:\\site & iisreset"),
(false, vec!["iisreset".to_string()])
);
assert_eq!(restart_programs(Dialect::Posix, "cd /srv && echo ok"), (false, Vec::new()));
}
#[test]
fn the_restart_program_is_found_past_sudo_and_assignments() {
assert_eq!(restart_program("pm2 restart api"), Some((false, "pm2".into())));
assert_eq!(restart_program("sudo systemctl restart api"), Some((true, "systemctl".into())));
assert_eq!(restart_program("sudo -u web -n pm2 reload all"), Some((true, "pm2".into())));
assert_eq!(restart_program("NODE_ENV=production ./restart.sh"), Some((false, "./restart.sh".into())));
assert_eq!(restart_program("iisreset"), Some((false, "iisreset".into())));
assert_eq!(restart_program("sudo"), None);
assert_eq!(restart_program(""), None);
}
fn failed(check: &'static str) -> CheckView {
CheckView::fail(PATHS, check, None, "x")
}
#[test]
fn a_deploy_stops_at_the_earliest_step_a_failure_guards() {
assert_eq!(stops_at(&[failed("restart"), failed("writable")]), Some("upload"));
assert_eq!(stops_at(&[failed("disk"), failed("sign_in")]), Some("connect"));
assert_eq!(stops_at(&[failed("restart")]), Some("restart"));
assert_eq!(stops_at(&[failed("artifacts"), failed("sshd")]), Some("start"));
assert_eq!(stops_at(&[failed("web")]), None, "a down web server stops no deploy step");
let warned = CheckView::warn(PATHS, "disk", None, "x");
assert_eq!(warned.state, State::Warn);
assert_eq!(stops_at(&[warned]), None);
}
#[test]
fn the_probe_scripts_answer_through_a_real_shell() {
let stand = crate::ssh::tests::Stand::spawn_shell();
let session = stand.session();
let dialect = crate::shell::read_probe(&session.exec(crate::shell::PROBE).unwrap());
assert_eq!(dialect, if cfg!(windows) { Dialect::Windows } else { Dialect::Posix });
let root = tempfile::tempdir().unwrap();
let existing = root.path().join("site");
std::fs::create_dir(&existing).unwrap();
let dir = existing.display().to_string();
let probed = read_probe(&remote::exec(&session, &probe_command(dialect, &dir)).unwrap());
assert!(probed.exists && probed.writable, "{probed:?}");
assert!(probed.free_kib.is_some_and(|free| free > 0), "{probed:?}");
assert!(probed.user.is_some(), "{probed:?}");
assert_eq!(std::fs::read_dir(&existing).unwrap().count(), 0, "the probe file stayed behind");
let missing = existing.join("not").join("yet").display().to_string();
let probed = read_probe(&remote::exec(&session, &probe_command(dialect, &missing)).unwrap());
assert!(!probed.exists && probed.writable, "{probed:?}");
assert_eq!(std::path::Path::new(&probed.dir), existing.as_path(), "{probed:?}");
let path = crate::model::Path {
name: "site".into(),
dir: dir.clone(),
restart: Some(if cfg!(windows) { "cmd /C exit 0".into() } else { "sh -c true".into() }),
};
let checks = path_checks(&session, dialect, "deploy", &path, None);
let state = |check: &str| checks.iter().find(|c| c.check == check).map(|c| c.state);
assert_eq!(state("writable"), Some(State::Ok), "{checks:?}");
assert!(matches!(state("disk"), Some(State::Ok | State::Warn)), "{checks:?}");
assert_eq!(state("restart"), Some(State::Ok), "{checks:?}");
let gone = crate::model::Path {
restart: Some("no-such-program-anywhere --restart".into()),
..path
};
let checks = path_checks(&session, dialect, "deploy", &gone, None);
let restart = checks.iter().find(|c| c.check == "restart").unwrap();
assert_eq!(restart.state, State::Fail, "{checks:?}");
assert!(restart.message.contains("no-such-program-anywhere is not found"), "{}", restart.message);
}
#[cfg(unix)]
#[test]
fn a_directory_the_account_cannot_write_to_fails_with_the_grant() {
use std::os::unix::fs::PermissionsExt;
if unsafe { libc::geteuid() } == 0 {
return;
}
let stand = crate::ssh::tests::Stand::spawn_shell();
let session = stand.session();
let root = tempfile::tempdir().unwrap();
let locked = root.path().join("locked");
std::fs::create_dir(&locked).unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o555)).unwrap();
let path = crate::model::Path {
name: "site".into(),
dir: locked.display().to_string(),
restart: None,
};
let checks = path_checks(&session, Dialect::Posix, "deploy", &path, None);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
let writable = checks.iter().find(|c| c.check == "writable").unwrap();
assert_eq!(writable.state, State::Fail, "{checks:?}");
assert!(writable.fix.as_deref().is_some_and(|fix| fix.starts_with("sudo chown -R")), "{writable:?}");
assert_eq!(stops_at(&checks), Some("upload"));
}
#[test]
fn the_fixes_speak_the_server_s_dialect() {
assert_eq!(grant(Dialect::Posix, "/var/www/app", "deploy"), "sudo chown -R deploy '/var/www/app'");
assert_eq!(
grant(Dialect::Windows, "C:\\inetpub\\api", "deploy"),
"icacls \"C:\\inetpub\\api\" /grant deploy:(OI)(CI)M"
);
assert!(create(Dialect::Posix, "/srv/x", "deploy").starts_with("sudo mkdir -p '/srv/x'"));
}
}