use std::fmt;
use std::io::IsTerminal as _;
use std::path::PathBuf;
use kazari::Stream;
use kazari::prelude::*;
use crate::face;
use engenho_control_client::{
ClientError, ControlClient, RemotesConfig, Reply, remote, render, resolve_socket,
};
use engenho_control_types::ops::{
CancelConfirmation, CancelConfirmationRequest, CreateConfirmation, CreateConfirmationRequest,
};
use engenho_control_types::types;
use engenho_control_types::wire::{HttpParts, HttpRequest};
use engenho_control_types::{
AuthorityTier, CATALOG, ConfirmGate, ControlError, MediaType, OperationId, OperationSpec,
ParamLocation,
};
pub const EXIT_OK: u8 = 0;
pub const EXIT_USAGE: u8 = 2;
pub const EXIT_REFUSED: u8 = 3;
pub const EXIT_BLIND: u8 = 4;
pub const EXIT_ABORTED: u8 = 5;
const CONFIRMATION_HEADER: &str = "Engenho-Confirmation";
const PHRASE_FIELD: &str = "confirm_phrase";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Endpoint {
Local(Option<PathBuf>),
Remote(String),
}
impl Endpoint {
pub fn set(&mut self, flag: &str, value: String) -> Result<(), CtlUsage> {
if *self != Endpoint::Local(None) {
return Err(CtlUsage::TwoEndpoints);
}
*self = if flag == "--socket" {
Endpoint::Local(Some(PathBuf::from(value)))
} else {
Endpoint::Remote(value)
};
Ok(())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CtlCommand {
pub endpoint: Endpoint,
pub json: bool,
pub actor: Option<String>,
pub ceiling: Option<AuthorityTier>,
pub action: CtlAction,
}
#[derive(Debug, Clone, PartialEq)]
pub enum CtlAction {
List,
Call {
id: OperationId,
parts: HttpParts,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CtlUsage {
UnknownFlag(String),
TwoEndpoints,
NeedsValue(String),
BadCeiling(String),
UnknownResource(String),
MissingVerb {
resource: String,
verbs: Vec<&'static str>,
},
UnknownVerb {
resource: String,
verb: String,
verbs: Vec<&'static str>,
},
UnexpectedArgument {
op: OperationId,
arg: String,
},
MissingPathParam {
op: OperationId,
name: &'static str,
},
NoSuchParameter {
op: OperationId,
flag: String,
},
BodyNotJson(String),
Invalid {
op: OperationId,
detail: String,
},
}
impl fmt::Display for CtlUsage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let spelled = |op: &OperationId| op.spec().cli;
match self {
Self::UnknownFlag(flag) => write!(f, "unknown flag {flag}\n\n{Usage}"),
Self::TwoEndpoints => f.write_str("--socket and --remote name two daemons; give one"),
Self::NeedsValue(flag) => write!(f, "{flag} needs a value"),
Self::BadCeiling(tier) => {
write!(
f,
"--ceiling {tier:?}: expected observe, mutate or destructive"
)
}
Self::UnknownResource(resource) => {
write!(f, "unknown resource {resource:?}\n\n{Usage}")
}
Self::MissingVerb { resource, verbs } => {
write!(f, "{resource} needs a verb: {}", verbs.join(", "))
}
Self::UnknownVerb {
resource,
verb,
verbs,
} => write!(f, "{resource} has no verb {verb:?}: {}", verbs.join(", ")),
Self::UnexpectedArgument { op, arg } => {
let cli = spelled(op);
write!(f, "{} {} takes no argument {arg:?}", cli.resource, cli.verb)
}
Self::MissingPathParam { op, name } => {
let cli = spelled(op);
write!(f, "{} {} needs <{name}>", cli.resource, cli.verb)
}
Self::NoSuchParameter { op, flag } => {
let names: Vec<&str> = op.spec().params.iter().map(|p| p.name).collect();
write!(
f,
"{} has no parameter --{flag}; it takes: {}",
op.as_str(),
names.join(", ")
)
}
Self::BodyNotJson(err) => write!(f, "--body is not JSON: {err}"),
Self::Invalid { op, detail } => write!(f, "{}: {detail}", op.as_str()),
}
}
}
impl std::error::Error for CtlUsage {}
impl CtlCommand {
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, CtlUsage> {
let mut args = args.into_iter().peekable();
let mut command = Self {
endpoint: Endpoint::Local(None),
json: false,
actor: None,
ceiling: None,
action: CtlAction::List,
};
while let Some(arg) = args.peek().cloned() {
if !arg.starts_with("--") {
break;
}
args.next();
match arg.as_str() {
"--json" => command.json = true,
"--list" | "--help" => return Ok(command),
"--socket" | "--remote" => {
let given = value(&mut args, &arg)?;
command.endpoint.set(&arg, given)?;
}
"--actor" => command.actor = Some(value(&mut args, &arg)?),
"--ceiling" => {
let tier = value(&mut args, &arg)?;
command.ceiling = Some(tier.parse().map_err(|_| CtlUsage::BadCeiling(tier))?);
}
_ => return Err(CtlUsage::UnknownFlag(arg)),
}
}
let Some(resource) = args.next() else {
return Ok(command);
};
if resource == "help" || resource == "list" {
return Ok(command);
}
let rows: Vec<&OperationSpec> = CATALOG
.iter()
.filter(|r| r.cli.resource == resource)
.collect();
if rows.is_empty() {
return Err(CtlUsage::UnknownResource(resource));
}
let verbs: Vec<&'static str> = rows.iter().map(|r| r.cli.verb).collect();
let Some(verb) = args.next() else {
return Err(CtlUsage::MissingVerb { resource, verbs });
};
let Some(row) = rows.iter().find(|r| r.cli.verb == verb) else {
return Err(CtlUsage::UnknownVerb {
resource,
verb,
verbs,
});
};
let parts = request_parts(row, args, &mut command.json)?;
command.action = CtlAction::Call { id: row.id, parts };
Ok(command)
}
}
pub fn value(args: &mut impl Iterator<Item = String>, flag: &str) -> Result<String, CtlUsage> {
args.next()
.ok_or_else(|| CtlUsage::NeedsValue(flag.to_owned()))
}
fn request_parts(
row: &OperationSpec,
args: impl Iterator<Item = String>,
json: &mut bool,
) -> Result<HttpParts, CtlUsage> {
let mut path_params = row
.params
.iter()
.filter(|p| p.location == ParamLocation::Path);
let mut parts = HttpParts::default();
let mut body = serde_json::Map::new();
let mut whole_body: Option<serde_json::Value> = None;
let mut args = args.peekable();
while let Some(arg) = args.next() {
let Some(flag) = arg.strip_prefix("--") else {
let Some(param) = path_params.next() else {
return Err(CtlUsage::UnexpectedArgument { op: row.id, arg });
};
parts.path_params.push((param.name.to_owned(), arg));
continue;
};
if flag == "json" {
*json = true;
continue;
}
let text = value(&mut args, &arg)?;
if flag == "body" {
whole_body = Some(
serde_json::from_str(&text).map_err(|e| CtlUsage::BodyNotJson(e.to_string()))?,
);
continue;
}
let name = flag.replace('-', "_");
match row.params.iter().find(|p| {
p.location != ParamLocation::Path
&& p.name.replace('-', "_").eq_ignore_ascii_case(&name)
}) {
Some(p) if p.location == ParamLocation::Query => {
parts.query.push((p.name.to_owned(), text));
}
Some(p) => parts.headers.push((p.name.to_owned(), text)),
None if row.body => {
let v = serde_json::from_str(&text).unwrap_or(serde_json::Value::String(text));
body.insert(name, v);
}
None => {
return Err(CtlUsage::NoSuchParameter {
op: row.id,
flag: flag.to_owned(),
});
}
}
}
if let Some(missing) = path_params.next() {
return Err(CtlUsage::MissingPathParam {
op: row.id,
name: missing.name,
});
}
if row.body {
let mut value =
whole_body.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
if let serde_json::Value::Object(map) = &mut value {
map.extend(body);
}
parts.body = Some(value);
}
Ok(parts)
}
const USAGE_LINE: &str = "usage: engenho ctl [--socket PATH | --remote NAME] [--json] [--actor A] \
[--ceiling TIER] <resource> <verb> [args] [--param value]…";
pub struct Usage;
impl fmt::Display for Usage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "{USAGE_LINE}\n")?;
let width = CATALOG
.iter()
.map(|r| r.cli.resource.len() + r.cli.verb.len() + 1)
.max()
.unwrap_or(0);
for row in &CATALOG {
let pad = width.saturating_sub(row.cli.resource.len() + row.cli.verb.len() + 1);
write!(f, " {} {}{:pad$} ", row.cli.resource, row.cli.verb, "")?;
let mut path_width = 0;
for p in row
.params
.iter()
.filter(|p| p.location == ParamLocation::Path)
{
write!(f, "<{}> ", p.name)?;
path_width += p.name.len() + 3;
}
let pad = 13usize.saturating_sub(path_width);
writeln!(f, "{:pad$}{:<11} {}", "", row.tier, row.id.as_str())?;
}
Ok(())
}
}
fn rendered(id: OperationId, parts: &HttpParts) -> Result<HttpRequest, CtlUsage> {
render(id, parts).map_err(|bad| CtlUsage::Invalid {
op: bad.op,
detail: bad.detail,
})
}
pub async fn run(args: impl IntoIterator<Item = String>) -> u8 {
let command = match CtlCommand::parse(args) {
Ok(command) => command,
Err(usage) => {
eprintln!("engenho ctl: {usage}");
return EXIT_USAGE;
}
};
let CtlAction::Call { id, mut parts } = command.action else {
if face::colored(Stream::Stdout) {
println!("{USAGE_LINE}\n");
let _ = usage_table().print();
} else {
print!("{Usage}");
}
return EXIT_OK;
};
let handshake = match id.spec().gate {
ConfirmGate::Executes(op) if parts.header(CONFIRMATION_HEADER).is_none() => {
match reinit_request(op, &parts) {
Ok(request) => Some(request),
Err(usage) => {
eprintln!("engenho ctl: {usage}");
return EXIT_USAGE;
}
}
}
ConfirmGate::Executes(_) | ConfirmGate::Issue | ConfirmGate::Cancel | ConfirmGate::None => {
if let Err(usage) = rendered(id, &parts) {
eprintln!("engenho ctl: {usage}");
return EXIT_USAGE;
}
None
}
};
let (client, looked) = match connect(&command.endpoint, "engenho ctl") {
Ok(connected) => connected,
Err(code) => return code,
};
let client = client.with_actor(command.actor.clone().unwrap_or_else(|| "human".into()));
let client = match command.ceiling {
Some(tier) => client.with_ceiling(tier),
None => client,
};
if let Some(request) = handshake
&& let Err(code) = confirm(&client, request, &mut parts, &looked).await
{
return code;
}
let request = match rendered(id, &parts) {
Ok(request) => request,
Err(usage) => {
eprintln!("engenho ctl: {usage}");
return EXIT_USAGE;
}
};
match client.send(id, request).await {
Ok(reply) => {
print_reply(id, &reply, command.json);
EXIT_OK
}
Err(err) => report(&err, &looked),
}
}
pub fn connect(endpoint: &Endpoint, who: &str) -> Result<(ControlClient, Vec<PathBuf>), u8> {
match endpoint {
Endpoint::Local(explicit) => {
let socket = resolve_socket(explicit.clone());
match ControlClient::uds(&socket.path) {
Ok(client) => Ok((client, socket.considered)),
Err(err) => {
eprintln!("{who}: {err}");
Err(EXIT_BLIND)
}
}
}
Endpoint::Remote(name) => match remote_client(name) {
Ok(client) => Ok((client, Vec::new())),
Err(why) => {
eprintln!("{who}: --remote {name}: {why}");
Err(EXIT_USAGE)
}
},
}
}
pub fn report(err: &ClientError, looked: &[PathBuf]) -> u8 {
report_as("engenho ctl", err, looked)
}
pub fn report_as(who: &str, err: &ClientError, looked: &[PathBuf]) -> u8 {
if face::colored(Stream::Stderr) {
return report_styled(err, looked);
}
match err {
ClientError::Control(ControlError::Refused(r)) => {
eprintln!("refused ({}): {}", r.reason, r.because);
for legal in &r.legal {
eprintln!(" instead: {legal}");
}
EXIT_REFUSED
}
ClientError::Control(ControlError::Blind(b)) => {
eprintln!("blind ({}): {}", b.reason, b.because);
EXIT_BLIND
}
ClientError::Unreachable { .. } => {
eprintln!("{who}: {err}");
if !looked.is_empty() {
let looked: Vec<String> = looked.iter().map(|p| p.display().to_string()).collect();
eprintln!(" looked at: {}", looked.join(", "));
eprintln!(" (--socket PATH or $ENGENHO_CONTROL_SOCKET names it outright)");
}
EXIT_BLIND
}
ClientError::Protocol(_) => {
eprintln!("{who}: {err}");
EXIT_BLIND
}
}
}
fn report_styled(err: &ClientError, looked: &[PathBuf]) -> u8 {
let (code, callout, notes) = match err {
ClientError::Control(ControlError::Refused(r)) => (
EXIT_REFUSED,
kazari::error(r.because.clone())
.with_title(["refused (", &r.reason.to_string(), ")"].concat()),
r.legal
.iter()
.map(|legal| ["instead: ", legal].concat())
.collect(),
),
ClientError::Control(ControlError::Blind(b)) => (
EXIT_BLIND,
kazari::warn(b.because.clone())
.with_title(["blind (", &b.reason.to_string(), ")"].concat()),
Vec::new(),
),
ClientError::Unreachable { .. } => {
let mut notes = Vec::new();
if !looked.is_empty() {
let looked: Vec<String> = looked.iter().map(|p| p.display().to_string()).collect();
notes.push(["looked at: ", &looked.join(", ")].concat());
notes.push("--socket PATH or $ENGENHO_CONTROL_SOCKET names it outright".to_owned());
}
(
EXIT_BLIND,
kazari::error(err.to_string()).with_title("unreachable"),
notes,
)
}
ClientError::Protocol(_) => (
EXIT_BLIND,
kazari::error(err.to_string()).with_title("protocol"),
Vec::new(),
),
};
let _ = callout.eprint();
for note in notes {
let _ = kazari::note(note).eprint();
}
code
}
fn usage_table() -> kazari::Table {
let mut table = kazari::table(["command", "arguments", "tier", "operation"]);
for row in &CATALOG {
let path: Vec<String> = row
.params
.iter()
.filter(|p| p.location == ParamLocation::Path)
.map(|p| ["<", p.name, ">"].concat())
.collect();
let tier = match row.tier {
AuthorityTier::Observe => Role::Info,
AuthorityTier::Mutate => Role::Warn,
AuthorityTier::Destructive => Role::Error,
};
table = table.row_fragments([
Fragment::styled([row.cli.resource, " ", row.cli.verb].concat(), Role::Text),
Fragment::styled(path.join(" "), Role::Ident),
Fragment::styled(row.tier.to_string(), tier),
Fragment::styled(row.id.as_str(), Role::TextDim),
]);
}
table
}
fn reinit_request(
op: types::ReinitOp,
parts: &HttpParts,
) -> Result<types::ReinitRequest, CtlUsage> {
let mut fields = match &parts.body {
Some(serde_json::Value::Object(fields)) => fields.clone(),
Some(_) | None => serde_json::Map::new(),
};
fields.remove(PHRASE_FIELD);
fields.insert(
"operation".into(),
serde_json::Value::String(op.to_string()),
);
serde_json::from_value(serde_json::Value::Object(fields)).map_err(|e| CtlUsage::Invalid {
op: OperationId::CreateConfirmation,
detail: e.to_string(),
})
}
async fn confirm(
client: &ControlClient,
request: types::ReinitRequest,
parts: &mut HttpParts,
looked: &[PathBuf],
) -> Result<(), u8> {
let challenge = client
.call::<CreateConfirmation>(&CreateConfirmationRequest {
body: types::ConfirmationRequest { request },
})
.await
.map_err(|err| report(&err, looked))?;
show(&challenge, client.endpoint());
let given = parts
.body
.as_ref()
.and_then(|body| body.get(PHRASE_FIELD))
.and_then(serde_json::Value::as_str)
.map(str::to_owned);
let phrase = match given {
Some(phrase) => Some(phrase),
None if std::io::stdin().is_terminal() => prompt(&challenge.phrase_hint).await,
None => {
eprintln!(
"stdin is not a terminal: pass --confirm-phrase {} to confirm",
challenge.phrase_hint
);
None
}
};
if phrase.as_deref() != Some(challenge.phrase_hint.as_str()) {
let _ = client
.call::<CancelConfirmation>(&CancelConfirmationRequest {
confirmation: challenge.id.clone(),
})
.await;
eprintln!("aborted: nothing was done");
return Err(EXIT_ABORTED);
}
parts
.headers
.push((CONFIRMATION_HEADER.to_owned(), challenge.id.to_string()));
if let Some(serde_json::Value::Object(body)) = &mut parts.body {
body.insert(
PHRASE_FIELD.into(),
serde_json::Value::String(challenge.phrase_hint),
);
}
Ok(())
}
fn show(challenge: &types::Challenge, endpoint: &str) {
let bound = &challenge.bound;
eprintln!(
"{} on cluster {:?}, node {:?}, at {endpoint}",
bound.operation, bound.cluster_name, bound.node_name
);
let ca = match &bound.ca {
types::CaBinding::Present(sha256) => ["sha256:", sha256.as_str()].concat(),
types::CaBinding::Absent => "none".to_owned(),
types::CaBinding::Unreadable => "unreadable".to_owned(),
};
eprintln!(" CA: {ca}");
for line in &challenge.blast_radius {
eprintln!(" - {line}");
}
eprintln!(" (this challenge stands until {})", challenge.expires_at);
}
async fn prompt(hint: &str) -> Option<String> {
eprint!("type the cluster's name ({hint}) to confirm: ");
tokio::task::spawn_blocking(|| {
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.ok()
.filter(|n| *n > 0)
.map(|_| line.trim().to_owned())
})
.await
.ok()
.flatten()
}
fn remote_client(name: &str) -> Result<ControlClient, String> {
let dir = remote::config_dir().map_err(|e| e.to_string())?;
let remotes = RemotesConfig::load(&dir).map_err(|e| e.to_string())?;
let endpoint = remotes.get(&dir, name).map_err(|e| e.to_string())?;
let key = remote::read_key(&remote::key_path(&dir, name, Some(endpoint)))
.map_err(|e| e.to_string())?;
ControlClient::remote(endpoint, &key).map_err(|e| e.to_string())
}
fn print_reply(id: OperationId, reply: &Reply, json: bool) {
let text = String::from_utf8_lossy(&reply.body);
if id.spec().response_media == MediaType::Yaml {
print!("{text}");
return;
}
let Ok(value) = serde_json::from_slice::<serde_json::Value>(&reply.body) else {
println!("{text}");
return;
};
if !json && face::colored(Stream::Stdout) {
let _ = face::document(&value).print();
return;
}
let rendered = if json {
serde_json::to_string_pretty(&value).unwrap_or_else(|_| text.into_owned())
} else {
serde_yaml::to_string(&value).unwrap_or_else(|_| text.into_owned())
};
println!("{}", rendered.trim_end());
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(line: &str) -> Result<CtlCommand, CtlUsage> {
CtlCommand::parse(line.split_whitespace().map(str::to_owned))
}
fn call(line: &str) -> (OperationId, HttpParts) {
match parse(line).expect("parses").action {
CtlAction::Call { id, parts } => (id, parts),
CtlAction::List => panic!("{line}: parsed as a listing"),
}
}
fn spelled(id: OperationId, rest: &[&str]) -> String {
let cli = id.spec().cli;
[&[cli.resource, cli.verb][..], rest].concat().join(" ")
}
#[test]
fn every_operation_has_exactly_one_spelling() {
for row in &CATALOG {
let same: Vec<_> = CATALOG
.iter()
.filter(|r| r.cli == row.cli)
.map(|r| r.id)
.collect();
assert_eq!(same, [row.id], "{} {}", row.cli.resource, row.cli.verb);
}
}
#[test]
fn globals_resource_verb_and_parameters_are_read() {
let command = parse("--socket /x.sock --json runtime show").expect("parses");
assert_eq!(
command.endpoint,
Endpoint::Local(Some(PathBuf::from("/x.sock")))
);
assert!(command.json);
let remote = parse("--remote plo runtime show").expect("parses");
assert_eq!(remote.endpoint, Endpoint::Remote("plo".into()));
assert_eq!(
parse("--remote plo --socket /x.sock runtime show"),
Err(CtlUsage::TwoEndpoints),
"one daemon at a time"
);
assert!(matches!(
command.action,
CtlAction::Call {
id: OperationId::GetRuntime,
..
}
));
let (id, parts) = call("logs list --after 5 --wait-ms 100 --level warn");
assert_eq!(id, OperationId::ListLogs);
assert_eq!(
parts.query,
vec![
("after".to_string(), "5".to_string()),
("wait_ms".to_string(), "100".to_string()),
("level".to_string(), "warn".to_string()),
]
);
}
#[test]
fn a_path_parameter_is_positional_and_body_fields_are_flags() {
let (id, parts) = call(&spelled(OperationId::GetChild, &["kubelet"]));
assert_eq!(id, OperationId::GetChild);
assert_eq!(
parts.path_params,
vec![("child".to_string(), "kubelet".to_string())]
);
let (_, parts) = call(&spelled(
OperationId::StopRuntime,
&["--hold", "across_relaunch"],
));
assert_eq!(
parts.body,
Some(serde_json::json!({"hold": "across_relaunch"}))
);
let (_, bare) = call(&spelled(OperationId::StopRuntime, &[]));
assert_eq!(
bare.body,
Some(serde_json::json!({})),
"a body op always sends one"
);
}
#[test]
fn mistakes_are_usage_errors_that_say_what_is_accepted() {
assert!(matches!(
parse("nonsense show"),
Err(CtlUsage::UnknownResource(_))
));
let err = parse("runtime frobnicate").expect_err("no such verb");
assert!(err.to_string().contains("show"), "{err}");
assert!(matches!(
parse("--ceiling god runtime show"),
Err(CtlUsage::BadCeiling(_))
));
let err = parse(&spelled(OperationId::GetChild, &[])).expect_err("needs a child");
assert!(err.to_string().contains("<child>"), "{err}");
let usage = Usage.to_string();
for row in &CATALOG {
assert!(
usage.contains(row.id.as_str()),
"{} unlisted",
row.id.as_str()
);
}
}
#[test]
fn a_bad_parameter_is_caught_by_the_daemons_own_parser_before_sending() {
let (id, parts) = call("logs list --after notanumber");
assert!(rendered(id, &parts).is_err());
let (id, parts) = call("logs list --after 7");
let request = rendered(id, &parts).expect("renders");
assert_eq!(request.query, vec![("after", "7".to_string())]);
}
}