use std::net::SocketAddr;
use clap::{Parser, Subcommand};
use miette::Result;
use serde_json::Value;
use super::names::{ask, joined, list, tell, text};
use crate::actions::Context;
#[derive(Debug, Clone, Parser)]
#[clap(verbatim_doc_comment)]
pub struct CertsArgs {
#[command(subcommand)]
command: Option<Command>,
#[arg(long, global = true)]
server_addr: Vec<SocketAddr>,
#[arg(long, global = true)]
json: bool,
}
#[derive(Debug, Clone, Subcommand)]
enum Command {
List,
Request {
name: String,
#[arg(long = "type", short = 't', required = true)]
application_type: String,
},
Collect,
}
pub async fn run(args: CertsArgs, _ctx: Context) -> Result<()> {
let (endpoint, query) = match args.command.clone().unwrap_or(Command::List) {
Command::List => ("status", Vec::new()),
Command::Collect => ("collect", Vec::new()),
Command::Request {
name,
application_type,
} => (
"request",
vec![("name", name), ("type", application_type)],
),
};
let answer = if endpoint == "status" {
ask(&args.server_addr, endpoint, &query).await?
} else {
tell(&args.server_addr, endpoint, &query).await?
};
if args.json {
println!("{}", serde_json::to_string_pretty(&answer).unwrap_or_default());
return Ok(());
}
report(&answer);
Ok(())
}
fn report(answer: &Value) {
let entitled = answer["entitled"].as_bool().unwrap_or(false);
let paused = answer["paused"].as_bool().unwrap_or(false);
println!(
"TLS grant: {}{}",
if entitled { "held" } else { "not held" },
if paused { " (canopy reports this server paused)" } else { "" },
);
println!("Domains: {}", joined(answer, "domains"));
let held: Vec<String> = answer["held"]
.as_array()
.map(|rows| {
rows.iter()
.map(|row| {
format!(
"{} — expires {}{}",
text(row, "name"),
text(row, "notAfter"),
if row["usable"].as_bool().unwrap_or(false) {
""
} else {
" (not servable)"
},
)
})
.collect()
})
.unwrap_or_default();
list("Chains this host serves", &held);
let canopy: Vec<String> = answer["canopyHolds"]
.as_array()
.map(|rows| {
rows.iter()
.map(|row| {
let mut line = format!("{} — expires {}", text(row, "name"), text(row, "notAfter"));
if row["revoked"].as_bool().unwrap_or(false) {
line.push_str(" (revoked)");
}
if row["keyMustBeReplaced"].as_bool().unwrap_or(false) {
line.push_str(" (key must be replaced)");
}
line
})
.collect()
})
.unwrap_or_default();
list("Certificates canopy holds", &canopy);
let orders: Vec<String> = answer["orders"]
.as_array()
.map(|rows| {
rows.iter()
.filter(|row| row["state"].as_str() != Some("issued") || !row["reason"].is_null())
.map(order_line)
.collect()
})
.unwrap_or_default();
if !orders.is_empty() {
list("Orders in flight", &orders);
}
if let Some(err) = answer["lastError"].as_str() {
println!("Last pass failed: {err}");
}
}
fn order_line(row: &Value) -> String {
let state = row["state"]
.as_str()
.filter(|state| !state.is_empty())
.unwrap_or("failed");
let mut line = format!("{} — {state}", text(row, "name"));
if let Some(application_type) = row["applicationType"].as_str() {
line.push_str(&format!(" (as {application_type})"));
}
if let Some(reason) = row["reason"].as_str() {
line.push_str(&format!(": {reason}"));
}
line
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn a_refusal_shows_as_its_state_with_canopys_reason_and_the_type_sent() {
let line = order_line(&json!({
"name": "app.example.com",
"state": "undeclared",
"applicationType": "msupply",
"reason": "declare app.example.com on an application",
}));
assert_eq!(
line,
"app.example.com — undeclared (as msupply): declare app.example.com on an application"
);
}
#[test]
fn an_order_with_no_state_is_failed_not_blank() {
let line = order_line(&json!({
"name": "app.example.com",
"state": "",
"reason": "reaching canopy: timed out",
}));
assert_eq!(line, "app.example.com — failed: reaching canopy: timed out");
}
#[test]
fn requesting_needs_an_application_type() {
use clap::Parser as _;
assert!(CertsArgs::try_parse_from(["certs", "request", "app.example.com"]).is_err());
assert!(
CertsArgs::try_parse_from(["certs", "request", "app.example.com", "--type", "msupply"])
.is_ok()
);
}
}