aviso_cli/lib.rs
1// (C) Copyright 2024- ECMWF and individual contributors.
2//
3// This software is licensed under the terms of the Apache Licence Version 2.0
4// which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
5// In applying this licence, ECMWF does not waive the privileges and immunities
6// granted to it by virtue of its status as an intergovernmental organisation nor
7// does it submit to any jurisdiction.
8
9//! Library entry point for the `aviso` command-line client.
10//!
11//! The `aviso` binary (`src/main.rs`) is a thin shim over [`run`], and the
12//! `pyaviso` Python wheel's bundled `aviso` console command calls the same
13//! [`run`] entry point through the `aviso-py` extension. Keeping the whole
14//! CLI in the library (clap parsing, tracing setup, async dispatch, and
15//! exit-code mapping) means both surfaces share one code path. Aside from the
16//! second-Ctrl+C hard-exit escape hatch in the private `cancel` module,
17//! [`std::process::exit`] lives in the binary, not in this library.
18
19#![allow(
20 clippy::doc_markdown,
21 reason = "clap derive doc-comments are operator-facing --help text; backticks render literally in clap output and degrade UX"
22)]
23
24use std::ffi::OsString;
25use std::path::PathBuf;
26
27use anyhow::{Context, Result};
28use clap::{Parser, Subcommand, ValueEnum};
29
30/// Color output mode for the global `--color auto|always|never` flag.
31///
32/// Translated to a per-stream `bool` via [`color_enabled`]: tracing
33/// uses `stderr`'s TTY state; the echo trigger uses `stdout`'s. The
34/// `auto` variant honours the `NO_COLOR` env var; `always` overrides
35/// it (operator-supplied explicit override wins); `never` always
36/// suppresses ANSI escapes.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
38pub(crate) enum ColorMode {
39 /// Emit colors when the target output stream is a TTY and `NO_COLOR`
40 /// is unset.
41 Auto,
42 /// Emit colors regardless of TTY state. Overrides `NO_COLOR`.
43 Always,
44 /// Never emit colors. Default.
45 Never,
46}
47
48/// Pure helper that resolves a `ColorMode` to a `bool` for a specific
49/// output stream.
50///
51/// Inputs are explicit (no env access, no TTY probing) so the function
52/// is unit-testable without `std::env::set_var` (which is `unsafe` in
53/// Rust 2024 and unsound to call after worker threads have spawned).
54/// The CLI computes the inputs once at startup and passes the result
55/// to (a) the tracing subscriber's `.with_ansi(...)` and (b) the lib's
56/// [`aviso::set_echo_color_enabled`] before any listener spawns.
57fn color_enabled(mode: ColorMode, is_terminal: bool, no_color_present: bool) -> bool {
58 match mode {
59 ColorMode::Always => true,
60 ColorMode::Never => false,
61 ColorMode::Auto => !no_color_present && is_terminal,
62 }
63}
64
65#[cfg(test)]
66#[allow(
67 clippy::unwrap_used,
68 reason = "test code: unwrap on pure logic assertions is the expected diagnostic"
69)]
70mod tests {
71 use super::{ColorMode, color_enabled};
72
73 #[test]
74 fn always_emits_color_regardless_of_tty_and_no_color() {
75 assert!(color_enabled(ColorMode::Always, false, false));
76 assert!(color_enabled(ColorMode::Always, false, true));
77 assert!(color_enabled(ColorMode::Always, true, false));
78 assert!(color_enabled(ColorMode::Always, true, true));
79 }
80
81 #[test]
82 fn never_suppresses_color_regardless_of_tty_and_no_color() {
83 assert!(!color_enabled(ColorMode::Never, false, false));
84 assert!(!color_enabled(ColorMode::Never, false, true));
85 assert!(!color_enabled(ColorMode::Never, true, false));
86 assert!(!color_enabled(ColorMode::Never, true, true));
87 }
88
89 #[test]
90 fn auto_emits_color_only_when_tty_and_no_color_unset() {
91 assert!(color_enabled(ColorMode::Auto, true, false));
92 assert!(
93 !color_enabled(ColorMode::Auto, true, true),
94 "NO_COLOR set => suppressed in auto mode"
95 );
96 assert!(
97 !color_enabled(ColorMode::Auto, false, false),
98 "non-TTY => suppressed in auto mode"
99 );
100 assert!(!color_enabled(ColorMode::Auto, false, true));
101 }
102
103 #[test]
104 fn always_overrides_no_color_per_explicit_operator_choice() {
105 assert!(
106 color_enabled(ColorMode::Always, true, true),
107 "--color always must override NO_COLOR (explicit operator override wins)"
108 );
109 }
110}
111
112mod auth;
113mod cancel;
114mod client_builder;
115mod commands;
116mod config;
117mod error;
118mod exit;
119mod from_value;
120mod listener;
121mod listener_file;
122mod output;
123mod paths;
124mod tracing_format;
125
126/// Top-level CLI. Holds the global flags shared across every
127/// subcommand plus the dispatch into [`Commands`].
128#[derive(Debug, Parser)]
129#[command(
130 name = "aviso",
131 version = aviso::VERSION,
132 about = "Command-line client for aviso-server",
133 long_about = "The `aviso` command-line client for ECMWF's aviso-server notification service. \
134 Configuration lives in ~/.config/aviso/config.yaml by default; flag and env \
135 overrides take precedence per the documented config-layering rule. See \
136 `aviso <SUBCOMMAND> --help` for per-command details, or \
137 https://github.com/ecmwf/aviso-client/tree/main/docs/src/cli for the full \
138 operator documentation.",
139)]
140pub(crate) struct Cli {
141 /// Path to the YAML config file. Default:
142 /// ~/.config/aviso/config.yaml. Env override:
143 /// AVISO_CLIENT_CONFIG_FILE.
144 #[arg(short = 'c', long, value_name = "PATH", global = true)]
145 config: Option<PathBuf>,
146
147 /// Path to the JsonFileStore state file. Default:
148 /// ~/.config/aviso/state.json. Env override: AVISO_STATE_FILE.
149 #[arg(long, value_name = "PATH", global = true)]
150 state_file: Option<PathBuf>,
151
152 /// Override the aviso-server base URL. Env override:
153 /// AVISO_BASE_URL.
154 #[arg(long, value_name = "URL", global = true)]
155 base_url: Option<String>,
156
157 /// Bearer auth token. Mutually exclusive with --username and
158 /// --password. Env override: AVISO_TOKEN.
159 #[arg(
160 long,
161 value_name = "TOKEN",
162 global = true,
163 conflicts_with_all = ["username", "password"]
164 )]
165 token: Option<String>,
166
167 /// Basic auth username. Requires --password. Mutually exclusive
168 /// with --token. Env override: AVISO_USERNAME.
169 #[arg(long, value_name = "USERNAME", global = true, requires = "password")]
170 username: Option<String>,
171
172 /// Basic auth password. Requires --username. Mutually exclusive
173 /// with --token. Env override: AVISO_PASSWORD.
174 #[arg(long, value_name = "PASSWORD", global = true, requires = "username")]
175 password: Option<String>,
176
177 /// Path to a PEM-encoded CA bundle to trust in addition to the
178 /// system root store. Repeatable: pass --ca-bundle multiple
179 /// times to add multiple certificates.
180 #[arg(
181 long,
182 value_name = "PATH",
183 global = true,
184 long_help = "Path to PEM-encoded CA bundle to trust IN ADDITION TO the system roots. \
185 Use when the aviso-server is fronted by an internal CA not in the system \
186 trust store (private deployments behind corporate roots, self-hosted \
187 clusters with their own ACME setup, similar). The system root store stays \
188 in effect; --ca-bundle only adds, never replaces. Repeatable: pass \
189 --ca-bundle multiple times for multiple certificates. The 'TLS' section at \
190 https://github.com/ecmwf/aviso-client/blob/main/docs/src/cli/configuration.md \
191 has end-to-end setup steps including how to fetch a PEM cert from a \
192 running server."
193 )]
194 ca_bundle: Vec<PathBuf>,
195
196 /// Disable TLS certificate validation entirely. Insecure by
197 /// design.
198 #[arg(
199 long,
200 global = true,
201 long_help = "Disable TLS certificate validation entirely. INSECURE; intended only for \
202 short-lived dev work against a self-signed aviso-server when shipping the \
203 cert via --ca-bundle is not practical. Logs WARN \
204 `event.name=cli.tls.insecure_mode` once per invocation so log scrapers can \
205 flag misuse. The right production move is always --ca-bundle, never this. \
206 See the 'TLS' section at \
207 https://github.com/ecmwf/aviso-client/blob/main/docs/src/cli/configuration.md."
208 )]
209 danger_accept_invalid_certs: bool,
210
211 /// Force JSON output (overrides TTY-aware default).
212 #[arg(long, global = true)]
213 json: bool,
214
215 /// Color output mode. `never` (default) disables all ANSI escapes;
216 /// `always` emits colors in the human-readable output paths
217 /// regardless of TTY (overrides NO_COLOR); `auto` emits colors
218 /// in the human-readable paths when the target output stream is
219 /// a TTY and NO_COLOR is unset. A value is REQUIRED:
220 /// `--color auto|always|never`. ANSI is never emitted into JSON
221 /// (machine consumers via pipe/file) regardless of this flag.
222 /// Per-stream: tracing checks stderr, echo trigger checks stdout,
223 /// so `aviso listen --color auto | jq` correctly keeps stderr
224 /// colored (TTY) and stdout JSON (pipe).
225 #[arg(long, value_enum, default_value_t = ColorMode::Never, global = true)]
226 color: ColorMode,
227
228 /// Increase verbosity. Repeatable: -v = DEBUG, -vv = TRACE.
229 /// Affects the aviso crates only; third-party crates (hyper,
230 /// h2, reqwest, rustls) stay at WARN regardless. When the
231 /// AVISO_LOG env var is set, its EnvFilter directive overrides
232 /// this flag (operator-supplied policy is authoritative); use
233 /// AVISO_LOG=h2=debug,hyper=debug,aviso=debug to also see
234 /// transport-level diagnostics.
235 #[arg(short = 'v', long, action = clap::ArgAction::Count, global = true)]
236 verbose: u8,
237
238 #[command(subcommand)]
239 command: Commands,
240}
241
242/// Top-level subcommand enum. Each variant maps to one subcommand
243/// of the `aviso` binary; the handler dispatch lives in [`dispatch`].
244#[derive(Debug, Subcommand)]
245enum Commands {
246 /// Publish one notification to /api/v1/notification.
247 ///
248 /// Pyaviso-parity: parameters are a comma-separated key=value
249 /// list; `event=<TYPE>` is required, `data=<JSON>` is optional
250 /// (becomes the payload), all other key=value pairs enter the
251 /// identifier map.
252 Notify {
253 /// Comma-separated key=value list. e.g.
254 /// `event=mars,class=od,stream=oper,data={"x":1}`.
255 parameters: String,
256 },
257
258 /// Run one or more listeners against /api/v1/watch.
259 ///
260 /// Listeners come from the positional YAML files (each carrying
261 /// its own top-level `listeners:` list) when supplied, OR from
262 /// the `listeners:` section of the global config when not.
263 /// Spawns every resolved listener concurrently; a single
264 /// listener's error WARNs but does not cancel siblings.
265 Listen {
266 /// Listener YAML files. Each file's `listeners:` list is
267 /// concatenated in argv order; positional files REPLACE
268 /// (not merge with) the global config's `listeners:`
269 /// section for this invocation. Ignored when `--event` and
270 /// `--identifiers` are both supplied (inline mode takes
271 /// precedence, matching `aviso replay`).
272 listener_files: Vec<PathBuf>,
273
274 /// Force MemoryStore for the invocation. Ignores any
275 /// configured `state_file`.
276 #[arg(long)]
277 no_state_store: bool,
278
279 /// Listener-level cursor override applied uniformly to every
280 /// resolved listener. Accepts the same seven forms as
281 /// `aviso replay --from`. When set, the listener's per-YAML
282 /// `from_id` / `from_date` is overridden.
283 #[arg(long, value_name = "VALUE")]
284 from: Option<String>,
285
286 /// Inline ad-hoc listener: event type to listen for, without
287 /// a YAML file. Requires `--identifiers`. The inline pair
288 /// takes precedence over any positional YAML files.
289 #[arg(long, value_name = "TYPE", requires = "identifiers")]
290 event: Option<String>,
291
292 /// Inline ad-hoc listener: identifiers filter as a JSON
293 /// object (e.g. `'{"class":"od"}'`). Requires `--event`.
294 /// The inline listener runs with a single default echo
295 /// trigger; for other triggers, use a YAML file instead.
296 #[arg(long, value_name = "JSON", requires = "event")]
297 identifiers: Option<String>,
298 },
299
300 /// Replay historical notifications from a server-side cursor.
301 Replay {
302 /// Listener name from the resolved listener set. Required
303 /// when more than one listener resolves.
304 #[arg(long, value_name = "NAME")]
305 listener: Option<String>,
306
307 /// Override the listener's `event:` for an ad-hoc replay.
308 /// Requires --identifiers.
309 #[arg(long, value_name = "TYPE", requires = "identifiers")]
310 event: Option<String>,
311
312 /// Override the listener's `identifiers:` for an ad-hoc
313 /// replay (JSON object). Requires --event.
314 #[arg(long, value_name = "JSON", requires = "event")]
315 identifiers: Option<String>,
316
317 /// Required cursor. Accepts a u64 sequence id OR one of
318 /// six date forms; see the '`--from` value formats' section
319 /// at <https://github.com/ecmwf/aviso-client/blob/main/docs/src/cli/configuration.md>
320 /// for the full list and the pure-digit-always-id ambiguity rule.
321 #[arg(long, value_name = "VALUE", required = true)]
322 from: String,
323
324 /// Listener YAML files. Same resolution semantics as
325 /// `aviso listen`.
326 listener_files: Vec<PathBuf>,
327 },
328
329 /// Schema operations.
330 #[command(subcommand)]
331 Schema(SchemaSubcommand),
332
333 /// Destructive admin operations. Each leaf requires --yes.
334 #[command(subcommand)]
335 Admin(AdminSubcommand),
336
337 /// Configuration introspection.
338 #[command(subcommand)]
339 Config(ConfigSubcommand),
340
341 /// Print shell completions for the chosen shell to stdout.
342 Completions {
343 /// Target shell. One of: bash, zsh, fish, powershell,
344 /// elvish.
345 shell: clap_complete::Shell,
346 },
347}
348
349#[derive(Debug, Subcommand)]
350enum SchemaSubcommand {
351 /// List all schemas registered on the server.
352 List,
353 /// Get the schema for one event type.
354 Get {
355 /// Event type whose schema to fetch.
356 event_type: String,
357 },
358}
359
360#[derive(Debug, Subcommand)]
361enum AdminSubcommand {
362 /// Wipe every notification for one event-type stream.
363 WipeStream {
364 /// Event type whose stream to wipe.
365 event_type: String,
366 /// Required confirmation. Without it the command exits 2
367 /// with usage.
368 #[arg(long)]
369 yes: bool,
370 },
371 /// Wipe every notification across every stream.
372 WipeAll {
373 /// Required confirmation. Without it the command exits 2
374 /// with usage.
375 #[arg(long)]
376 yes: bool,
377 },
378 /// Delete a single notification by its CloudEvents id
379 /// (`<event_type>@<sequence>`).
380 Delete {
381 /// CloudEvents id of the notification to delete.
382 notification_id: String,
383 /// Required confirmation. Without it the command exits 2
384 /// with usage.
385 #[arg(long)]
386 yes: bool,
387 },
388}
389
390#[derive(Debug, Subcommand)]
391enum ConfigSubcommand {
392 /// Dump the resolved config (flag-over-env-over-file applied)
393 /// to stdout.
394 Dump {
395 /// Mask tokens and passwords in the output.
396 #[arg(long)]
397 redact: bool,
398 },
399}
400
401fn init_tracing(verbose: u8, ansi: bool) -> Result<()> {
402 use std::io::IsTerminal as _;
403 use tracing_subscriber::EnvFilter;
404 use tracing_subscriber::filter::LevelFilter;
405 use tracing_subscriber::fmt;
406
407 // Filter policy is per-crate. The CLI binary and the core
408 // library both compile under the crate name `aviso` (the
409 // binary's `[[bin]] name = "aviso"` makes its module_path
410 // resolve to `aviso`, same as the lib), so a single `aviso`
411 // directive covers both. Every other crate (hyper, h2,
412 // reqwest, rustls, etc.) stays at WARN regardless of -v so
413 // the operator does not get flooded with HTTP/2 frame logs
414 // when they asked for "a bit more detail from aviso". Power
415 // users who want transport diagnostics set `AVISO_LOG`
416 // explicitly (e.g. `AVISO_LOG=h2=debug,hyper=debug,aviso=debug`),
417 // and that operator-supplied directive overrides -v entirely.
418 let our_level = match verbose {
419 0 => "info",
420 1 => "debug",
421 _ => "trace",
422 };
423 let filter = if let Ok(directives) = std::env::var("AVISO_LOG") {
424 EnvFilter::builder()
425 .with_default_directive(LevelFilter::WARN.into())
426 .parse_lossy(directives)
427 } else {
428 let directive_str = format!("warn,aviso={our_level}");
429 EnvFilter::try_new(directive_str).context("constructing default tracing filter")?
430 };
431
432 // Output format is TTY-aware. Interactive operators see a
433 // compact human-readable line per event (colored only when the
434 // operator opts in via `--color auto|always`, off by default);
435 // headless deployments (piped stderr, systemd, CI) get OTel-JSON
436 // for log aggregators (never colored regardless of the flag).
437 // Detection is on stderr (not stdout) so the common
438 // `aviso listen | tee log.txt` pattern correctly keeps the
439 // operator's terminal human-friendly while the file gets the
440 // operator's chosen trigger output.
441 // `try_init` returns Err only when a global subscriber is already
442 // installed. That happens when `run` is called more than once in a single
443 // process: the test suite calls `_run_cli` repeatedly, and a host program
444 // embedding the extension could too. A failed install is treated as
445 // success there, leaving the first subscriber in place.
446 if std::io::stderr().is_terminal() {
447 let _ = fmt()
448 .with_env_filter(filter)
449 .with_writer(std::io::stderr)
450 .with_target(false)
451 .with_timer(tracing_format::ShortClockTimer)
452 .with_ansi(ansi)
453 .compact()
454 .try_init();
455 } else {
456 let _ = fmt()
457 .with_env_filter(filter)
458 .with_writer(std::io::stderr)
459 .event_format(tracing_format::OtelLogFormat::new())
460 .try_init();
461 }
462
463 Ok(())
464}
465
466async fn dispatch(cli: Cli) -> Result<()> {
467 let resolved = config::resolve(
468 cli.config.as_ref(),
469 cli.state_file.as_ref(),
470 cli.base_url.as_deref(),
471 cli.token.as_deref(),
472 cli.username.as_deref(),
473 cli.password.as_deref(),
474 &cli.ca_bundle,
475 cli.danger_accept_invalid_certs,
476 cli.json,
477 cli.verbose,
478 )?;
479
480 if resolved.tls_danger_accept_invalid_certs.value {
481 tracing::warn!(
482 event.name = "cli.tls.insecure_mode",
483 "TLS certificate validation disabled by --danger-accept-invalid-certs; do not use in production"
484 );
485 }
486
487 tracing::debug!(
488 event.name = "cli.config.resolved",
489 config_path = %resolved.config_path.value.display(),
490 state_path = %resolved.state_path.value.display(),
491 base_url_set = resolved.base_url.is_some(),
492 auth_provider_set = resolved.auth_provider.is_some(),
493 listeners_count = resolved.listeners.len(),
494 "resolved configuration"
495 );
496
497 match cli.command {
498 Commands::Notify { parameters } => commands::notify::run(&resolved, ¶meters).await,
499 Commands::Listen {
500 listener_files,
501 no_state_store,
502 from,
503 event,
504 identifiers,
505 } => {
506 commands::listen::run(
507 &resolved,
508 &listener_files,
509 no_state_store,
510 from.as_deref(),
511 event.as_deref(),
512 identifiers.as_deref(),
513 )
514 .await
515 }
516 Commands::Replay {
517 listener,
518 event,
519 identifiers,
520 from,
521 listener_files,
522 } => {
523 commands::replay::run(
524 &resolved,
525 &listener_files,
526 listener.as_deref(),
527 event.as_deref(),
528 identifiers.as_deref(),
529 &from,
530 )
531 .await
532 }
533 Commands::Schema(sub) => match sub {
534 SchemaSubcommand::List => commands::schema::run_list(&resolved).await,
535 SchemaSubcommand::Get { event_type } => {
536 commands::schema::run_get(&resolved, &event_type).await
537 }
538 },
539 Commands::Admin(sub) => match sub {
540 AdminSubcommand::WipeStream { event_type, yes } => {
541 if !yes {
542 return Err(exit::usage_error("aviso admin wipe-stream requires --yes"));
543 }
544 commands::admin::run_wipe_stream(&resolved, &event_type).await
545 }
546 AdminSubcommand::WipeAll { yes } => {
547 if !yes {
548 return Err(exit::usage_error("aviso admin wipe-all requires --yes"));
549 }
550 commands::admin::run_wipe_all(&resolved).await
551 }
552 AdminSubcommand::Delete {
553 notification_id,
554 yes,
555 } => {
556 if !yes {
557 return Err(exit::usage_error("aviso admin delete requires --yes"));
558 }
559 commands::admin::run_delete(&resolved, ¬ification_id).await
560 }
561 },
562 Commands::Config(ConfigSubcommand::Dump { redact }) => {
563 commands::config_dump::run(&resolved, redact)
564 }
565 Commands::Completions { shell } => commands::completions::run(shell),
566 }
567}
568
569/// Runs the `aviso` command-line client to completion and returns the
570/// process exit code.
571///
572/// This is the single entry point shared by the `aviso` binary
573/// (`src/main.rs`) and the bundled `aviso` console command shipped in the
574/// `pyaviso` Python wheel through the `aviso-py` extension. It owns argument
575/// parsing, tracing setup, the async runtime, and the exit-code mapping, and
576/// it does not call [`std::process::exit`] on its normal paths, so an embedding
577/// process (the Python interpreter) keeps control of its own lifecycle. The one
578/// exception is the second-Ctrl+C hard exit during `listen` / `replay`, which
579/// terminates the process immediately by design.
580///
581/// `args` is the full argument vector including the program name at index 0,
582/// matching [`std::env::args_os`] and `sys.argv`.
583///
584/// Exit codes: `0` success, `1` runtime error, `2` usage error. A clap parse
585/// failure prints its message and returns clap's own exit code (`2`), while
586/// `--help` and `--version` print and return `0`.
587pub fn run<I, T>(args: I) -> i32
588where
589 I: IntoIterator<Item = T>,
590 T: Into<OsString> + Clone,
591{
592 use std::io::IsTerminal as _;
593
594 let cli = match Cli::try_parse_from(args) {
595 Ok(cli) => cli,
596 Err(err) => {
597 let _ = err.print();
598 return err.exit_code();
599 }
600 };
601 let no_color = std::env::var_os("NO_COLOR").is_some();
602 let stderr_color = color_enabled(cli.color, std::io::stderr().is_terminal(), no_color);
603 let stdout_color = color_enabled(cli.color, std::io::stdout().is_terminal(), no_color);
604 aviso::set_echo_color_enabled(stdout_color);
605 if let Err(e) = init_tracing(cli.verbose, stderr_color) {
606 let _ = output::write_stderr_line(&format!("error: failed to initialise tracing: {e:#}"));
607 return exit::RUNTIME_ERROR;
608 }
609 let runtime = match tokio::runtime::Builder::new_multi_thread()
610 .enable_all()
611 .build()
612 {
613 Ok(runtime) => runtime,
614 Err(e) => {
615 let _ =
616 output::write_stderr_line(&format!("error: failed to start async runtime: {e:#}"));
617 return exit::RUNTIME_ERROR;
618 }
619 };
620 match runtime.block_on(dispatch(cli)) {
621 Ok(()) => exit::SUCCESS,
622 Err(e) => {
623 let code = exit::exit_code_for_anyhow(&e);
624 error::format_chain(&e);
625 code
626 }
627 }
628}