#![allow(
clippy::doc_markdown,
reason = "clap derive doc-comments are operator-facing --help text; backticks render literally in clap output and degrade UX"
)]
use std::ffi::OsString;
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand, ValueEnum};
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub(crate) enum ColorMode {
Auto,
Always,
Never,
}
fn color_enabled(mode: ColorMode, is_terminal: bool, no_color_present: bool) -> bool {
match mode {
ColorMode::Always => true,
ColorMode::Never => false,
ColorMode::Auto => !no_color_present && is_terminal,
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
reason = "test code: unwrap on pure logic assertions is the expected diagnostic"
)]
mod tests {
use super::{ColorMode, color_enabled};
#[test]
fn always_emits_color_regardless_of_tty_and_no_color() {
assert!(color_enabled(ColorMode::Always, false, false));
assert!(color_enabled(ColorMode::Always, false, true));
assert!(color_enabled(ColorMode::Always, true, false));
assert!(color_enabled(ColorMode::Always, true, true));
}
#[test]
fn never_suppresses_color_regardless_of_tty_and_no_color() {
assert!(!color_enabled(ColorMode::Never, false, false));
assert!(!color_enabled(ColorMode::Never, false, true));
assert!(!color_enabled(ColorMode::Never, true, false));
assert!(!color_enabled(ColorMode::Never, true, true));
}
#[test]
fn auto_emits_color_only_when_tty_and_no_color_unset() {
assert!(color_enabled(ColorMode::Auto, true, false));
assert!(
!color_enabled(ColorMode::Auto, true, true),
"NO_COLOR set => suppressed in auto mode"
);
assert!(
!color_enabled(ColorMode::Auto, false, false),
"non-TTY => suppressed in auto mode"
);
assert!(!color_enabled(ColorMode::Auto, false, true));
}
#[test]
fn always_overrides_no_color_per_explicit_operator_choice() {
assert!(
color_enabled(ColorMode::Always, true, true),
"--color always must override NO_COLOR (explicit operator override wins)"
);
}
}
mod auth;
mod cancel;
mod client_builder;
mod commands;
mod config;
mod error;
mod exit;
mod from_value;
mod listener;
mod listener_file;
mod output;
mod paths;
mod tracing_format;
#[derive(Debug, Parser)]
#[command(
name = "aviso",
version = aviso::VERSION,
about = "Command-line client for aviso-server",
long_about = "The `aviso` command-line client for ECMWF's aviso-server notification service. \
Configuration lives in ~/.config/aviso/config.yaml by default; flag and env \
overrides take precedence per the documented config-layering rule. See \
`aviso <SUBCOMMAND> --help` for per-command details, or \
https://github.com/ecmwf/aviso-client/tree/main/docs/src/cli for the full \
operator documentation.",
)]
pub(crate) struct Cli {
#[arg(short = 'c', long, value_name = "PATH", global = true)]
config: Option<PathBuf>,
#[arg(long, value_name = "PATH", global = true)]
state_file: Option<PathBuf>,
#[arg(long, value_name = "URL", global = true)]
base_url: Option<String>,
#[arg(
long,
value_name = "TOKEN",
global = true,
conflicts_with_all = ["username", "password"]
)]
token: Option<String>,
#[arg(long, value_name = "USERNAME", global = true, requires = "password")]
username: Option<String>,
#[arg(long, value_name = "PASSWORD", global = true, requires = "username")]
password: Option<String>,
#[arg(
long,
value_name = "PATH",
global = true,
long_help = "Path to PEM-encoded CA bundle to trust IN ADDITION TO the system roots. \
Use when the aviso-server is fronted by an internal CA not in the system \
trust store (private deployments behind corporate roots, self-hosted \
clusters with their own ACME setup, similar). The system root store stays \
in effect; --ca-bundle only adds, never replaces. Repeatable: pass \
--ca-bundle multiple times for multiple certificates. The 'TLS' section at \
https://github.com/ecmwf/aviso-client/blob/main/docs/src/cli/configuration.md \
has end-to-end setup steps including how to fetch a PEM cert from a \
running server."
)]
ca_bundle: Vec<PathBuf>,
#[arg(
long,
global = true,
long_help = "Disable TLS certificate validation entirely. INSECURE; intended only for \
short-lived dev work against a self-signed aviso-server when shipping the \
cert via --ca-bundle is not practical. Logs WARN \
`event.name=cli.tls.insecure_mode` once per invocation so log scrapers can \
flag misuse. The right production move is always --ca-bundle, never this. \
See the 'TLS' section at \
https://github.com/ecmwf/aviso-client/blob/main/docs/src/cli/configuration.md."
)]
danger_accept_invalid_certs: bool,
#[arg(long, global = true)]
json: bool,
#[arg(long, value_enum, default_value_t = ColorMode::Never, global = true)]
color: ColorMode,
#[arg(short = 'v', long, action = clap::ArgAction::Count, global = true)]
verbose: u8,
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
Notify {
parameters: String,
},
Listen {
listener_files: Vec<PathBuf>,
#[arg(long)]
no_state_store: bool,
#[arg(long, value_name = "VALUE")]
from: Option<String>,
#[arg(long, value_name = "TYPE", requires = "identifiers")]
event: Option<String>,
#[arg(long, value_name = "JSON", requires = "event")]
identifiers: Option<String>,
},
Replay {
#[arg(long, value_name = "NAME")]
listener: Option<String>,
#[arg(long, value_name = "TYPE", requires = "identifiers")]
event: Option<String>,
#[arg(long, value_name = "JSON", requires = "event")]
identifiers: Option<String>,
#[arg(long, value_name = "VALUE", required = true)]
from: String,
listener_files: Vec<PathBuf>,
},
#[command(subcommand)]
Schema(SchemaSubcommand),
#[command(subcommand)]
Admin(AdminSubcommand),
#[command(subcommand)]
Config(ConfigSubcommand),
Completions {
shell: clap_complete::Shell,
},
}
#[derive(Debug, Subcommand)]
enum SchemaSubcommand {
List,
Get {
event_type: String,
},
}
#[derive(Debug, Subcommand)]
enum AdminSubcommand {
WipeStream {
event_type: String,
#[arg(long)]
yes: bool,
},
WipeAll {
#[arg(long)]
yes: bool,
},
Delete {
notification_id: String,
#[arg(long)]
yes: bool,
},
}
#[derive(Debug, Subcommand)]
enum ConfigSubcommand {
Dump {
#[arg(long)]
redact: bool,
},
}
fn init_tracing(verbose: u8, ansi: bool) -> Result<()> {
use std::io::IsTerminal as _;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::filter::LevelFilter;
use tracing_subscriber::fmt;
let our_level = match verbose {
0 => "info",
1 => "debug",
_ => "trace",
};
let filter = if let Ok(directives) = std::env::var("AVISO_LOG") {
EnvFilter::builder()
.with_default_directive(LevelFilter::WARN.into())
.parse_lossy(directives)
} else {
let directive_str = format!("warn,aviso={our_level}");
EnvFilter::try_new(directive_str).context("constructing default tracing filter")?
};
if std::io::stderr().is_terminal() {
let _ = fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.with_target(false)
.with_timer(tracing_format::ShortClockTimer)
.with_ansi(ansi)
.compact()
.try_init();
} else {
let _ = fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.event_format(tracing_format::OtelLogFormat::new())
.try_init();
}
Ok(())
}
async fn dispatch(cli: Cli) -> Result<()> {
let resolved = config::resolve(
cli.config.as_ref(),
cli.state_file.as_ref(),
cli.base_url.as_deref(),
cli.token.as_deref(),
cli.username.as_deref(),
cli.password.as_deref(),
&cli.ca_bundle,
cli.danger_accept_invalid_certs,
cli.json,
cli.verbose,
)?;
if resolved.tls_danger_accept_invalid_certs.value {
tracing::warn!(
event.name = "cli.tls.insecure_mode",
"TLS certificate validation disabled by --danger-accept-invalid-certs; do not use in production"
);
}
tracing::debug!(
event.name = "cli.config.resolved",
config_path = %resolved.config_path.value.display(),
state_path = %resolved.state_path.value.display(),
base_url_set = resolved.base_url.is_some(),
auth_provider_set = resolved.auth_provider.is_some(),
listeners_count = resolved.listeners.len(),
"resolved configuration"
);
match cli.command {
Commands::Notify { parameters } => commands::notify::run(&resolved, ¶meters).await,
Commands::Listen {
listener_files,
no_state_store,
from,
event,
identifiers,
} => {
commands::listen::run(
&resolved,
&listener_files,
no_state_store,
from.as_deref(),
event.as_deref(),
identifiers.as_deref(),
)
.await
}
Commands::Replay {
listener,
event,
identifiers,
from,
listener_files,
} => {
commands::replay::run(
&resolved,
&listener_files,
listener.as_deref(),
event.as_deref(),
identifiers.as_deref(),
&from,
)
.await
}
Commands::Schema(sub) => match sub {
SchemaSubcommand::List => commands::schema::run_list(&resolved).await,
SchemaSubcommand::Get { event_type } => {
commands::schema::run_get(&resolved, &event_type).await
}
},
Commands::Admin(sub) => match sub {
AdminSubcommand::WipeStream { event_type, yes } => {
if !yes {
return Err(exit::usage_error("aviso admin wipe-stream requires --yes"));
}
commands::admin::run_wipe_stream(&resolved, &event_type).await
}
AdminSubcommand::WipeAll { yes } => {
if !yes {
return Err(exit::usage_error("aviso admin wipe-all requires --yes"));
}
commands::admin::run_wipe_all(&resolved).await
}
AdminSubcommand::Delete {
notification_id,
yes,
} => {
if !yes {
return Err(exit::usage_error("aviso admin delete requires --yes"));
}
commands::admin::run_delete(&resolved, ¬ification_id).await
}
},
Commands::Config(ConfigSubcommand::Dump { redact }) => {
commands::config_dump::run(&resolved, redact)
}
Commands::Completions { shell } => commands::completions::run(shell),
}
}
pub fn run<I, T>(args: I) -> i32
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
use std::io::IsTerminal as _;
let cli = match Cli::try_parse_from(args) {
Ok(cli) => cli,
Err(err) => {
let _ = err.print();
return err.exit_code();
}
};
let no_color = std::env::var_os("NO_COLOR").is_some();
let stderr_color = color_enabled(cli.color, std::io::stderr().is_terminal(), no_color);
let stdout_color = color_enabled(cli.color, std::io::stdout().is_terminal(), no_color);
aviso::set_echo_color_enabled(stdout_color);
if let Err(e) = init_tracing(cli.verbose, stderr_color) {
let _ = output::write_stderr_line(&format!("error: failed to initialise tracing: {e:#}"));
return exit::RUNTIME_ERROR;
}
let runtime = match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(e) => {
let _ =
output::write_stderr_line(&format!("error: failed to start async runtime: {e:#}"));
return exit::RUNTIME_ERROR;
}
};
match runtime.block_on(dispatch(cli)) {
Ok(()) => exit::SUCCESS,
Err(e) => {
let code = exit::exit_code_for_anyhow(&e);
error::format_chain(&e);
code
}
}
}