use std::io::IsTerminal;
use std::time::Duration;
use cli::{Commands, Format};
use commands::query;
use exit::ExitCode;
use output::Streams;
use shep_client::Client;
use shep_core::protocol::PROTOCOL_VERSION;
use crate::client::unreachable_message;
use crate::remote::Link;
use crate::{cli, commands, exit, output};
const RECOVERY_VERBS: [&str; 3] = ["kill", "daemon reload", "ping"];
fn recovery_verb(command: &Commands) -> Option<&'static str> {
match command {
Commands::Kill => Some("kill"),
Commands::Ping => Some("ping"),
Commands::Daemon(args) => match args.cmd {
Some(cli::DaemonCmd::Reload) => Some("daemon reload"),
None => None,
},
_ => None,
}
}
#[allow(dead_code)]
const READ_VERBS: [&str; 3] = ["flock", "describe", "bleats"];
fn read_verb(command: &Commands) -> Option<&'static str> {
match command {
Commands::Flock(_) => Some("flock"),
Commands::Describe(_) => Some("describe"),
Commands::Bleats(_) => Some("bleats"),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum VersionGuard {
Enforce,
Exempt,
Warn,
}
impl VersionGuard {
pub(crate) fn for_command(command: &Commands) -> Self {
match (recovery_verb(command), read_verb(command)) {
(Some(_), _) => Self::Exempt,
(None, Some(_)) => Self::Warn,
(None, None) => Self::Enforce,
}
}
}
const VERSION_SKEW_CAUSE: [&str; 2] = [
"`cargo install shep` replaced the binary. It did not restart the",
"shepherd, which is still running the old code.",
];
const HOST_BEHIND_CAUSE: [&str; 2] = [
"The shep installed on that host speaks an older wire protocol.",
"A reload runs the shep installed there; install this version there if they still differ.",
];
const HOST_AHEAD_CAUSE: [&str; 2] = [
"The shep installed on that host speaks a newer wire protocol.",
"That host runs its own install, and it is this machine that is behind.",
];
const LOCAL_CRATE_NOTICE: &str =
"Listing works, but verbs that change the flock refuse until it is reloaded.";
const HOST_CRATE_NOTICE: &str = concat!(
"The wire protocol matches, so verbs go ahead; install the same version ",
"there if you want them to match.",
);
const PROTOCOL_NOTICE: &str = "Read verbs go ahead, but verbs that change the flock refuse.";
pub(crate) const VERSION_SKEW_REMEDY: &str = RECOVERY_VERBS[1];
const UPGRADE_COMMAND: &str = "cargo install shep";
pub(crate) fn version_skew_command(host: Option<&str>) -> String {
match host {
Some(dest) => format!("ssh {dest} shep {VERSION_SKEW_REMEDY}"),
None => format!("shep {VERSION_SKEW_REMEDY}"),
}
}
fn protocol_fix(host: Option<&str>, host_ahead: bool) -> String {
if host_ahead {
UPGRADE_COMMAND.to_owned()
} else {
version_skew_command(host)
}
}
fn version_skew_instruction(fmt: Format, command: &str) -> String {
match fmt {
Format::Json => format!("Run `{command}`."),
Format::Table => format!("Run:\n {command}"),
}
}
fn shepherd_at(host: Option<&str>) -> String {
match host {
Some(dest) => format!("the shepherd on {dest}"),
None => "the running shepherd".to_owned(),
}
}
fn version_skew_summary(running: &str, host: Option<&str>) -> String {
format!(
"this shep is {}, {} is {running}",
env!("CARGO_PKG_VERSION"),
shepherd_at(host)
)
}
fn protocol_skew_summary(running: &str, peer: u32, host: Option<&str>) -> String {
format!(
"this shep is {} speaking protocol {PROTOCOL_VERSION}, {} is {running} speaking protocol {peer}",
env!("CARGO_PKG_VERSION"),
shepherd_at(host)
)
}
fn notice_line(summary: &str, tail: &str, command: &str) -> String {
let sentence = format!("{summary}. {tail} Run `{command}`.");
crate::terminal_safe::sanitise(&sentence).0
}
struct Refusal {
summary: String,
command: String,
cause: [&'static str; 2],
}
fn one_line_refusal(summary: &str, command: &str) -> String {
crate::terminal_safe::sanitise(&format!("{summary}. Run `{command}`.")).0
}
fn skew_of(client: &Client) -> Skew {
let ack = client.daemon();
let host = client.host();
let running = ack.daemon_version.as_str();
if host.is_some() {
if ack.protocol != PROTOCOL_VERSION {
let ahead = ack.protocol > PROTOCOL_VERSION;
let summary = protocol_skew_summary(running, ack.protocol, host);
let command = protocol_fix(host, ahead);
return Skew {
notice: Some(notice_line(&summary, PROTOCOL_NOTICE, &command)),
refusal: Some(Refusal {
summary,
command,
cause: if ahead {
HOST_AHEAD_CAUSE
} else {
HOST_BEHIND_CAUSE
},
}),
};
}
if running != env!("CARGO_PKG_VERSION") {
let summary = version_skew_summary(running, host);
let command = version_skew_command(host);
return Skew {
notice: Some(notice_line(&summary, HOST_CRATE_NOTICE, &command)),
refusal: None,
};
}
} else if running != env!("CARGO_PKG_VERSION") {
let summary = version_skew_summary(running, None);
let command = version_skew_command(None);
return Skew {
notice: Some(notice_line(&summary, LOCAL_CRATE_NOTICE, &command)),
refusal: Some(Refusal {
summary,
command,
cause: VERSION_SKEW_CAUSE,
}),
};
}
Skew {
notice: None,
refusal: None,
}
}
struct Skew {
notice: Option<String>,
refusal: Option<Refusal>,
}
pub(crate) fn host_skew(client: &Client, guard: VersionGuard) -> Result<Option<String>, String> {
let skew = skew_of(client);
match (guard, skew.refusal) {
(VersionGuard::Exempt, _) => Ok(None),
(VersionGuard::Warn, _) => Ok(skew.notice),
(VersionGuard::Enforce, None) => Ok(skew.notice),
(VersionGuard::Enforce, Some(refusal)) => {
Err(one_line_refusal(&refusal.summary, &refusal.command))
}
}
}
pub(crate) fn refuse_version_skew(
streams: &mut Streams<'_>,
client: &Client,
guard: VersionGuard,
) -> Result<(), ExitCode> {
let skew = skew_of(client);
let refusal = match (guard, skew.refusal) {
(VersionGuard::Exempt, _) => return Ok(()),
(VersionGuard::Warn, _) | (VersionGuard::Enforce, None) => {
if let Some(notice) = skew.notice {
streams.aside("version_skew", ¬ice);
}
return Ok(());
}
(VersionGuard::Enforce, Some(refusal)) => refusal,
};
let code = ExitCode::VersionSkew;
let Refusal {
summary,
command,
cause,
} = refusal;
match streams.fmt {
Format::Json => {
let cause = cause.join(" ");
let instruction = version_skew_instruction(Format::Json, &command);
streams.fail(code, &format!("{summary}. {cause} {instruction}"));
}
Format::Table => {
let cause = cause.join("\n");
let summary = crate::terminal_safe::sanitise(&summary).0;
let instruction = version_skew_instruction(Format::Table, &command);
streams.fail(code, &format!("{summary}\n\n{cause}\n\n{instruction}"));
}
}
Err(code)
}
pub(crate) async fn connect_client(
streams: &mut Streams<'_>,
link: &Link<'_>,
guard: VersionGuard,
) -> Result<Client, ExitCode> {
let dialled = match link {
Link::Local(paths) => Client::connect(&paths.socket).await,
Link::Host { dest, options } => Client::connect_ssh_with(dest, options).await,
};
match dialled {
Ok(client) => {
refuse_version_skew(streams, &client, guard)?;
Ok(client)
}
Err(err) => {
let code = ExitCode::from(&err);
Err(streams.fail(code, &unreachable_message(&err)))
}
}
}
pub(crate) async fn flock_command(
streams: &mut Streams<'_>,
link: &Link<'_>,
guard: VersionGuard,
args: &cli::FlockArgs,
) -> ExitCode {
flock_command_on(streams, link, guard, args, std::io::stdout().is_terminal()).await
}
pub(crate) async fn flock_command_on(
streams: &mut Streams<'_>,
link: &Link<'_>,
guard: VersionGuard,
args: &cli::FlockArgs,
stdout_is_terminal: bool,
) -> ExitCode {
let paths = match link {
Link::Local(paths) => *paths,
Link::Host { .. } if args.follow => {
return follow_flock_command(streams, link, guard, args, stdout_is_terminal).await;
}
Link::Host { dest, .. } => {
return match connect_client(streams, link, guard).await {
Ok(client) => query::flock_from(&client, streams, Some(dest), None).await,
Err(code) => code,
};
}
};
if args.follow {
return follow_flock_command(streams, link, guard, args, stdout_is_terminal).await;
}
let hosts = query::fleet::listed_hosts(streams, paths);
if !hosts.is_empty() {
return query::fleet::flock_all(streams, paths, guard, &hosts).await;
}
match Client::connect(&paths.socket).await {
Ok(client) => match refuse_version_skew(streams, &client, guard) {
Ok(()) => query::flock_local(&client, streams, paths).await,
Err(code) => code,
},
Err(err) if streams.fmt == Format::Json => {
let code = ExitCode::from(&err);
streams.fail(code, &unreachable_message(&err))
}
Err(shep_client::ConnectError::Connect { .. }) => query::flock_from_roll(streams, paths),
Err(err) => {
let code = ExitCode::from(&err);
streams.fail(code, &flock_connect_refusal_message(&err))
}
}
}
async fn follow_flock_command(
streams: &mut Streams<'_>,
link: &Link<'_>,
guard: VersionGuard,
args: &cli::FlockArgs,
stdout_is_terminal: bool,
) -> ExitCode {
if streams.fmt == Format::Json {
return streams.fail(
ExitCode::Usage,
"`--follow` redraws a table; `--format json` has no follow form",
);
}
if !stdout_is_terminal {
return streams.fail(
ExitCode::Usage,
"`--follow` needs a terminal; stdout is not one",
);
}
match connect_client(streams, link, guard).await {
Ok(client) => {
query::flock_follow(&client, streams, Duration::from_secs(args.interval)).await
}
Err(code) => code,
}
}
pub(crate) fn flock_connect_refusal_message(err: &shep_client::ConnectError) -> String {
format!("{err}; run `shep {VERSION_SKEW_REMEDY}`")
}
#[cfg(test)]
mod tests;