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!(
"$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 {
let requests = sudo_requests(restart);
let reply = remote::exec(session, &sudo_probe(&requests)).unwrap_or_default();
let rule = format!("allow it without a password for {user}: a NOPASSWD rule in sudoers");
match sudo_verdict(&read_sudo_probe(&reply), &requests) {
Sudo::Free => {}
Sudo::Password => {
return CheckView::fail(
PATHS,
"restart",
subject,
format!("restart `{restart}` - sudo asks {user} for a password, and a deploy has nobody to type it"),
)
.fix(rule);
}
Sudo::Refused(command) => {
return CheckView::fail(
PATHS,
"restart",
subject,
format!("restart `{restart}` - sudo does not let {user} run `{command}`"),
)
.fix(rule);
}
Sudo::Unproven(command) => {
return CheckView::warn(
PATHS,
"restart",
subject,
format!(
"restart `{restart}` - sudo lets {user} run `{command}`, and no rule `sudo -l` shows says it runs without a password; if it asks for one, the deploy fails"
),
)
.fix(rule);
}
}
}
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()))
}
#[derive(Debug, PartialEq, Eq)]
struct SudoRequest {
user: String,
words: Vec<String>,
}
fn sudo_requests(restart: &str) -> Vec<SudoRequest> {
let mut requests = Vec::new();
for part in commands_of(restart) {
let mut words = part.split_whitespace().skip_while(|word| word.contains('=') && !word.starts_with('-'));
if words.next() != Some("sudo") {
continue;
}
let (mut user, mut kept) = ("root".to_string(), Vec::new());
let mut words = words.peekable();
while let Some(word) = words.next_if(|word| word.starts_with('-')) {
const TAKES: [&str; 8] = ["-u", "-g", "-C", "-h", "-p", "-r", "-t", "-U"];
if !TAKES.contains(&word) {
continue;
}
let Some(value) = words.next() else { break };
if word == "-u" {
user = value.trim_matches(['"', '\'']).to_string();
}
if word == "-u" || word == "-g" {
kept.extend([word.to_string(), value.to_string()]);
}
}
kept.extend(words.map(str::to_string));
requests.push(SudoRequest { user, words: kept });
}
requests
}
fn sudo_probe(requests: &[SudoRequest]) -> String {
let mut script = String::from("if sudo -n true >/dev/null 2>&1; then echo free; exit 0; fi; ");
for request in requests {
let words = request.words.join(" ");
script.push_str(&format!(
"if o=$(sudo -n -l {words} 2>&1); then echo \"allowed $o\"; else echo \"refused $(printf %s \"$o\" | tr '\\n' ' ')\"; fi; "
));
}
script.push_str("echo rules; sudo -n -l 2>/dev/null; exit 0");
format!("sh -c {}", Dialect::Posix.quote(&script))
}
#[derive(Debug, Default, PartialEq, Eq)]
struct SudoProbe {
free: bool,
answers: Vec<Result<String, String>>,
rules: Vec<SudoRule>,
}
#[derive(Debug, PartialEq, Eq)]
struct SudoRule {
users: Vec<String>,
nopasswd: bool,
command: String,
}
fn read_sudo_probe(reply: &str) -> SudoProbe {
let mut probe = SudoProbe::default();
let mut lines = reply.lines().map(|line| line.trim_end_matches('\r'));
for line in lines.by_ref() {
match line.trim() {
"free" => probe.free = true,
"rules" => break,
line => {
if let Some(command) = line.strip_prefix("allowed ") {
probe.answers.push(Ok(command.trim().to_string()));
} else if let Some(why) = line.strip_prefix("refused") {
probe.answers.push(Err(why.trim().to_string()));
}
}
}
}
let mut listing = false;
let mut rule: Option<String> = None;
for line in lines {
let continued = line.starts_with(" ") || line.starts_with('\t');
match &mut rule {
Some(rule) if listing && continued => {
rule.push(' ');
rule.push_str(line.trim());
continue;
}
_ => {}
}
probe.rules.extend(rule.take().map(|rule| read_sudo_rule(&rule)).unwrap_or_default());
if line.contains("may run the following commands") {
listing = true;
} else if listing && line.starts_with(' ') && !line.trim().is_empty() {
rule = Some(line.trim().to_string());
} else if !line.trim().is_empty() {
listing = false;
}
}
probe.rules.extend(rule.map(|rule| read_sudo_rule(&rule)).unwrap_or_default());
probe
}
fn read_sudo_rule(line: &str) -> Vec<SudoRule> {
let (users, rest) = match line.strip_prefix('(').and_then(|rest| rest.split_once(')')) {
Some((runas, rest)) => {
let users = runas.split(':').next().unwrap_or_default();
(users.split(',').map(|user| user.trim().to_string()).collect(), rest)
}
None => (vec!["root".to_string()], line),
};
let mut nopasswd = false;
let mut rules = Vec::new();
for spec in rest.split(',') {
let mut spec = spec.trim();
while let Some((tag, after)) = spec
.split_once(':')
.filter(|(tag, _)| !tag.is_empty() && tag.chars().all(|c| c.is_ascii_uppercase() || c == '_'))
{
match tag {
"NOPASSWD" => nopasswd = true,
"PASSWD" => nopasswd = false,
_ => {}
}
spec = after.trim_start();
}
if !spec.is_empty() {
rules.push(SudoRule {
users: users.clone(),
nopasswd,
command: spec.to_string(),
});
}
}
rules
}
fn sudo_rule_covers(rule: &str, command: &str) -> bool {
if rule == "ALL" {
return true;
}
if rule.starts_with('!') {
return false;
}
match rule.split_once(char::is_whitespace) {
None => command.split_whitespace().next().is_some_and(|program| glob(rule, program)),
Some((path, "\"\"")) => glob(path, command.trim()),
Some(_) => glob(rule, command.trim()),
}
}
fn glob(pattern: &str, text: &str) -> bool {
let (pattern, text): (Vec<char>, Vec<char>) = (pattern.chars().collect(), text.chars().collect());
let (mut p, mut t, mut star, mut mark) = (0, 0, None, 0);
while t < text.len() {
if p < pattern.len() && (pattern[p] == '?' || pattern[p] == text[t]) {
p += 1;
t += 1;
} else if p < pattern.len() && pattern[p] == '*' {
star = Some(p);
mark = t;
p += 1;
} else if let Some(at) = star {
p = at + 1;
mark += 1;
t = mark;
} else {
return false;
}
}
pattern[p..].iter().all(|c| *c == '*')
}
#[derive(Debug, PartialEq, Eq)]
enum Sudo {
Free,
Password,
Refused(String),
Unproven(String),
}
fn sudo_verdict(probe: &SudoProbe, requests: &[SudoRequest]) -> Sudo {
if probe.free {
return Sudo::Free;
}
let mut verdict = Sudo::Free;
for (index, request) in requests.iter().enumerate() {
let spelled = request.words.join(" ");
match probe.answers.get(index) {
Some(Err(why)) if why.contains("password is required") => return Sudo::Password,
Some(Err(_)) => return Sudo::Refused(spelled),
Some(Ok(command)) => {
let applies = probe
.rules
.iter()
.rev()
.find(|rule| rule.users.iter().any(|user| user == "ALL" || *user == request.user) && sudo_rule_covers(&rule.command, command));
if !applies.is_some_and(|rule| rule.nopasswd) && verdict == Sudo::Free {
verdict = Sudo::Unproven(spelled);
}
}
None if verdict == Sudo::Free => verdict = Sudo::Unproven(spelled),
None => {}
}
}
verdict
}
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);
}
#[test]
fn the_sudo_commands_of_a_restart_are_picked_out() {
assert_eq!(
sudo_requests("cd /srv && sudo systemctl restart api && FOO=1 sudo -E -u web pm2 reload all; echo done"),
[
SudoRequest {
user: "root".into(),
words: vec!["systemctl".into(), "restart".into(), "api".into()],
},
SudoRequest {
user: "web".into(),
words: vec!["-u".into(), "web".into(), "pm2".into(), "reload".into(), "all".into()],
},
]
);
assert!(sudo_requests("docker compose up -d").is_empty());
let probe = sudo_probe(&sudo_requests("sudo systemctl restart api"));
assert!(probe.contains("sudo -n -l systemctl restart api"), "{probe}");
}
const PER_COMMAND: &str = "allowed /usr/bin/systemctl restart api\n\
rules\n\
Matching Defaults entries for deploy on stand:\n env_reset, mail_badpass\n\n\
User deploy may run the following commands on stand:\n\
\x20 (ALL : ALL) ALL\n\
\x20 (root) NOPASSWD: /usr/bin/systemctl restart api, /usr/bin/systemctl\n\
\x20 status api\n";
#[test]
fn the_sudo_probe_reply_is_read() {
let probe = read_sudo_probe(PER_COMMAND);
assert!(!probe.free);
assert_eq!(probe.answers, [Ok("/usr/bin/systemctl restart api".to_string())]);
let commands: Vec<(&str, bool)> = probe.rules.iter().map(|rule| (rule.command.as_str(), rule.nopasswd)).collect();
assert_eq!(
commands,
[
("ALL", false),
("/usr/bin/systemctl restart api", true),
("/usr/bin/systemctl status api", true)
]
);
assert_eq!(probe.rules[1].users, ["root"]);
assert!(read_sudo_probe("free\n").free);
assert_eq!(
read_sudo_probe("refused sudo: a password is required \nrules\n").answers,
[Err("sudo: a password is required".to_string())]
);
}
#[test]
fn a_nopasswd_rule_for_the_restart_alone_is_enough() {
let requests = sudo_requests("sudo systemctl restart api");
assert_eq!(sudo_verdict(&read_sudo_probe(PER_COMMAND), &requests), Sudo::Free);
let other = sudo_requests("sudo systemctl restart web");
let reply = PER_COMMAND.replace("allowed /usr/bin/systemctl restart api", "allowed /usr/bin/systemctl restart web");
assert_eq!(sudo_verdict(&read_sudo_probe(&reply), &other), Sudo::Unproven("systemctl restart web".into()));
let as_web = sudo_requests("sudo -u web systemctl restart api");
assert_eq!(
sudo_verdict(&read_sudo_probe(PER_COMMAND), &as_web),
Sudo::Unproven("-u web systemctl restart api".into())
);
assert_eq!(sudo_verdict(&read_sudo_probe("free\n"), &other), Sudo::Free);
assert_eq!(
sudo_verdict(&read_sudo_probe("refused sudo: a password is required\nrules\n"), &requests),
Sudo::Password
);
assert_eq!(
sudo_verdict(&read_sudo_probe("refused \nrules\n"), &requests),
Sudo::Refused("systemctl restart api".into())
);
assert_eq!(
sudo_verdict(&read_sudo_probe(""), &requests),
Sudo::Unproven("systemctl restart api".into()),
"a probe that said nothing proves nothing"
);
}
#[test]
fn a_rule_covers_a_command_as_sudoers_matches_it() {
assert!(sudo_rule_covers("ALL", "/usr/bin/anything at all"));
assert!(sudo_rule_covers("/usr/bin/systemctl", "/usr/bin/systemctl restart api"));
assert!(sudo_rule_covers("/usr/bin/systemctl restart *", "/usr/bin/systemctl restart api"));
assert!(!sudo_rule_covers("/usr/bin/systemctl restart api", "/usr/bin/systemctl restart web"));
assert!(sudo_rule_covers("/usr/bin/systemctl \"\"", "/usr/bin/systemctl"));
assert!(!sudo_rule_covers("/usr/bin/systemctl \"\"", "/usr/bin/systemctl restart api"));
assert!(!sudo_rule_covers("!/usr/bin/su", "/usr/bin/su"));
assert!(sudo_rule_covers("/usr/bin/*ctl", "/usr/bin/systemctl stop"));
}
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'"));
}
}