#![cfg_attr(test, allow(unused_crate_dependencies))]
use clap::{Args, CommandFactory, Parser, Subcommand};
use tracing_subscriber::EnvFilter;
mod commands;
mod namegen;
struct FilterWriter<W: std::io::Write> {
inner: W,
buf: Vec<u8>,
}
impl<W: std::io::Write> FilterWriter<W> {
fn new(inner: W) -> Self {
Self {
inner,
buf: Vec::new(),
}
}
}
impl<W: std::io::Write> std::io::Write for FilterWriter<W> {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
for &b in data {
self.buf.push(b);
if b == b'\n' {
if !self
.buf
.windows(b"completions".len())
.any(|w| w == b"completions")
{
self.inner.write_all(&self.buf)?;
}
self.buf.clear();
}
}
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
if !self.buf.is_empty()
&& !self
.buf
.windows(b"completions".len())
.any(|w| w == b"completions")
{
self.inner.write_all(&self.buf)?;
}
self.buf.clear();
self.inner.flush()
}
}
fn default_profile() -> String {
commands::utils::default_profile()
}
#[derive(Parser)]
#[command(name = "volli", about = "Distributed diagnostics CLI", version)]
struct Cli {
#[arg(long, global = true, default_value_t = default_profile(), value_hint = clap::ValueHint::Other)]
profile: String,
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
verbose: u8,
#[arg(short, long, global = true, action = clap::ArgAction::SetTrue)]
quiet: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Serve(ServeOpts),
Agent(AgentOpts),
Admin(AdminOpts),
Profile(ProfileOpts),
#[command(hide = true)]
Completions { shell: clap_complete::Shell },
}
#[derive(Args)]
struct AdminOpts {
#[command(subcommand)]
command: AdminCommand,
}
#[derive(Subcommand)]
enum AdminCommand {
AgentToken,
CoordToken,
}
#[derive(Args)]
struct ProfileOpts {
#[command(subcommand)]
command: ProfileCommand,
}
#[derive(Args)]
struct ProfileShow {
profile: String,
#[arg(long)]
coord: bool,
#[arg(long)]
agent: bool,
}
#[derive(Subcommand)]
enum ProfileCommand {
List,
Show(ProfileShow),
Delete(ProfileDelete),
Rename(ProfileRename),
Update(ProfileUpdate),
Export(ProfileExport),
Import(ProfileImport),
Edit(ProfileEdit),
}
#[derive(Args)]
struct ProfileDelete {
profile: String,
#[arg(long)]
agent: bool,
#[arg(long)]
serve: bool,
}
#[derive(Args)]
struct ProfileRename {
old: String,
new: String,
#[arg(long)]
coord: bool,
#[arg(long)]
agent: bool,
}
#[derive(Args)]
struct ProfileUpdate {
profile: String,
#[arg(long)]
add_join_host: Option<String>,
#[arg(long)]
add_join_token: Option<String>,
#[arg(long)]
remove_join_index: Option<usize>,
#[arg(long)]
join_tcp_port: Option<u16>,
#[arg(long)]
join_quic_port: Option<u16>,
#[arg(long)]
bind_host: Option<String>,
#[arg(long)]
tcp_port: Option<u16>,
#[arg(long)]
quic_port: Option<u16>,
#[arg(long)]
advertise_host: Option<String>,
#[arg(long)]
agent_whitelist: Option<String>,
#[arg(long)]
coord_whitelist: Option<String>,
#[arg(long)]
agent_secret: Option<String>,
}
#[derive(Args)]
struct ProfileExport {
profile: String,
#[arg(long)]
coord: bool,
#[arg(long)]
agent: bool,
#[arg(long)]
stdout: bool,
#[arg(long)]
output: Option<String>,
}
#[derive(Args)]
struct ProfileImport {
file: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
force: bool,
}
#[derive(Args)]
struct ProfileEdit {
profile: String,
#[arg(long)]
coord: bool,
#[arg(long)]
agent: bool,
}
#[derive(Args)]
struct ServeOpts {
#[arg(short, long)]
daemon: bool,
#[arg(long)]
bootstrap: bool,
#[arg(long)]
force: bool,
#[arg(long)]
join: Option<String>,
#[arg(long)]
bind: Option<String>,
#[arg(long, alias = "host")]
advertise_host: Option<String>,
#[arg(long)]
join_host: Option<String>,
#[arg(long)]
join_tcp_port: Option<u16>,
#[arg(long)]
join_quic_port: Option<u16>,
#[arg(long)]
tcp_port: Option<u16>,
#[arg(long)]
quic_port: Option<u16>,
#[arg(long)]
cert: Option<String>,
#[arg(long)]
key: Option<String>,
#[arg(long)]
config_dir: Option<String>,
#[arg(long)]
update_profile: bool,
}
#[derive(Args)]
struct AgentOpts {
#[arg(short, long)]
daemon: bool,
#[arg(default_value = "localhost")]
connect: String,
#[arg(long)]
join: Option<String>,
#[arg(long)]
force: bool,
#[arg(long)]
protocol: Option<String>,
#[arg(long)]
join_host: Option<String>,
#[arg(long)]
config_dir: Option<String>,
#[arg(long)]
update_profile: bool,
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let level = if cli.quiet {
"warn"
} else {
match cli.verbose {
0 => {
if cfg!(debug_assertions) {
"debug"
} else {
"info"
}
}
1 => "debug",
_ => "trace",
}
};
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level));
tracing_subscriber::fmt().with_env_filter(filter).init();
match cli.command {
Commands::Completions { shell } => {
let mut cmd = Cli::command();
let stdout = std::io::stdout();
let handle = stdout.lock();
let mut writer = FilterWriter::new(handle);
clap_complete::generate(shell, &mut cmd, "volli", &mut writer);
}
Commands::Serve(opts) => {
commands::serve::run(cli.profile.clone(), opts).await;
}
Commands::Agent(opts) => {
commands::agent::run(cli.profile.clone(), opts).await;
}
Commands::Admin(opts) => {
commands::admin::run(cli.profile.clone(), opts).await;
}
Commands::Profile(opts) => {
commands::profile::run(opts);
}
}
}
#[cfg(test)]
mod tests {
use eyre::Report;
async fn send_cmd(profile: &str, cmd: &str) -> Result<(), Report> {
commands::utils::send_cmd(profile, cmd).await
}
fn cmd_socket_path(profile: &str) -> std::path::PathBuf {
commands::utils::cmd_socket_path(profile)
}
fn export_path(profile: &str, output: Option<&str>) -> String {
commands::utils::export_path(profile, output)
}
use super::*;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
#[tokio::test]
async fn send_cmd_missing_socket_errors() {
let path = cmd_socket_path("missing");
let _ = std::fs::remove_file(&path);
let res = send_cmd("missing", "hi").await;
assert!(res.is_err());
}
#[tokio::test]
async fn send_cmd_writes_and_reads() {
let path = cmd_socket_path("ok");
let _ = std::fs::remove_file(&path);
let listener = tokio::net::UnixListener::bind(&path).unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut reader = tokio::io::BufReader::new(stream);
let mut cmd = String::new();
reader.read_line(&mut cmd).await.unwrap();
assert_eq!(cmd, "ping\n");
reader.get_mut().write_all(b"pong\n").await.unwrap();
});
send_cmd("ok", "ping").await.unwrap();
server.await.unwrap();
std::fs::remove_file(&path).ok();
}
#[test]
fn export_path_defaults() {
assert_eq!(export_path("p1", None), "p1.yaml");
assert_eq!(export_path("p1", Some("out.yaml")), "out.yaml");
}
#[test]
fn socket_path_contains_profile() {
let path = cmd_socket_path("abc");
let filename = path.file_name().unwrap().to_string_lossy();
assert_eq!(filename, "volli-abc.sock");
}
}