Skip to main content

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 identifiers;
121mod listener;
122mod listener_file;
123mod output;
124mod paths;
125mod tracing_format;
126
127/// Top-level CLI. Holds the global flags shared across every
128/// subcommand plus the dispatch into [`Commands`].
129#[derive(Debug, Parser)]
130#[command(
131    name = "aviso",
132    version = aviso::VERSION,
133    about = "Command-line client for aviso-server",
134    long_about = "The `aviso` command-line client for ECMWF's aviso-server notification service. \
135                  Configuration lives in ~/.config/aviso/config.yaml by default; flag and env \
136                  overrides take precedence per the documented config-layering rule. See \
137                  `aviso <SUBCOMMAND> --help` for per-command details, or \
138                  https://github.com/ecmwf/aviso-client/tree/main/docs/src/cli for the full \
139                  operator documentation.",
140)]
141pub(crate) struct Cli {
142    /// Path to the YAML config file. Default:
143    /// ~/.config/aviso/config.yaml. Env override:
144    /// AVISO_CLIENT_CONFIG_FILE.
145    #[arg(short = 'c', long, value_name = "PATH", global = true)]
146    config: Option<PathBuf>,
147
148    /// Path to the JsonFileStore state file. Default:
149    /// ~/.config/aviso/state.json. Env override: AVISO_STATE_FILE.
150    #[arg(long, value_name = "PATH", global = true)]
151    state_file: Option<PathBuf>,
152
153    /// Override the aviso-server base URL. Env override:
154    /// AVISO_BASE_URL.
155    #[arg(long, value_name = "URL", global = true)]
156    base_url: Option<String>,
157
158    /// Bearer auth token. Mutually exclusive with --username and
159    /// --password. Env override: AVISO_TOKEN. Flags are visible to
160    /// other local users in the process list and end up in shell
161    /// history; prefer the env var or the credentials file.
162    #[arg(
163        long,
164        value_name = "TOKEN",
165        global = true,
166        conflicts_with_all = ["username", "password"]
167    )]
168    token: Option<String>,
169
170    /// Basic auth username. Requires --password. Mutually exclusive
171    /// with --token. Env override: AVISO_USERNAME.
172    #[arg(long, value_name = "USERNAME", global = true, requires = "password")]
173    username: Option<String>,
174
175    /// Basic auth password. Requires --username. Mutually exclusive
176    /// with --token. Env override: AVISO_PASSWORD. Flags are visible
177    /// to other local users in the process list and end up in shell
178    /// history; prefer the env var or the credentials file.
179    #[arg(long, value_name = "PASSWORD", global = true, requires = "username")]
180    password: Option<String>,
181
182    /// Path to a PEM-encoded CA bundle to trust in addition to the
183    /// system root store. Repeatable: pass --ca-bundle multiple
184    /// times to add multiple certificates.
185    #[arg(
186        long,
187        value_name = "PATH",
188        global = true,
189        long_help = "Path to PEM-encoded CA bundle to trust IN ADDITION TO the system roots. \
190                     Use when the aviso-server is fronted by an internal CA not in the system \
191                     trust store (private deployments behind corporate roots, self-hosted \
192                     clusters with their own ACME setup, similar). The system root store stays \
193                     in effect; --ca-bundle only adds, never replaces. Repeatable: pass \
194                     --ca-bundle multiple times for multiple certificates. The 'TLS' section at \
195                     https://github.com/ecmwf/aviso-client/blob/main/docs/src/cli/configuration.md \
196                     has end-to-end setup steps including how to fetch a PEM cert from a \
197                     running server."
198    )]
199    ca_bundle: Vec<PathBuf>,
200
201    /// Disable TLS certificate validation entirely. Insecure by
202    /// design.
203    #[arg(
204        long,
205        global = true,
206        long_help = "Disable TLS certificate validation entirely. INSECURE; intended only for \
207                     short-lived dev work against a self-signed aviso-server when shipping the \
208                     cert via --ca-bundle is not practical. Logs WARN \
209                     `event.name=cli.tls.insecure_mode` once per invocation so log scrapers can \
210                     flag misuse. The right production move is always --ca-bundle, never this. \
211                     See the 'TLS' section at \
212                     https://github.com/ecmwf/aviso-client/blob/main/docs/src/cli/configuration.md."
213    )]
214    danger_accept_invalid_certs: bool,
215
216    /// Force JSON output (overrides TTY-aware default).
217    #[arg(long, global = true)]
218    json: bool,
219
220    /// Color output mode. `never` (default) disables all ANSI escapes;
221    /// `always` emits colors in the human-readable output paths
222    /// regardless of TTY (overrides NO_COLOR); `auto` emits colors
223    /// in the human-readable paths when the target output stream is
224    /// a TTY and NO_COLOR is unset. A value is REQUIRED:
225    /// `--color auto|always|never`. ANSI is never emitted into JSON
226    /// (machine consumers via pipe/file) regardless of this flag.
227    /// Per-stream: tracing checks stderr, echo trigger checks stdout,
228    /// so `aviso listen --color auto | jq` correctly keeps stderr
229    /// colored (TTY) and stdout JSON (pipe).
230    #[arg(long, value_enum, default_value_t = ColorMode::Never, global = true)]
231    color: ColorMode,
232
233    /// Increase verbosity. Repeatable: -v = DEBUG, -vv = TRACE.
234    /// Affects the aviso crates only; third-party crates (hyper,
235    /// h2, reqwest, rustls) stay at WARN regardless. When the
236    /// AVISO_LOG env var is set, its EnvFilter directive overrides
237    /// this flag (operator-supplied policy is authoritative); use
238    /// AVISO_LOG=h2=debug,hyper=debug,aviso=debug to also see
239    /// transport-level diagnostics.
240    #[arg(short = 'v', long, action = clap::ArgAction::Count, global = true)]
241    verbose: u8,
242
243    #[command(subcommand)]
244    command: Commands,
245}
246
247/// Top-level subcommand enum. Each variant maps to one subcommand
248/// of the `aviso` binary; the handler dispatch lives in [`dispatch`].
249#[derive(Debug, Subcommand)]
250enum Commands {
251    /// Publish one notification to /api/v1/notification.
252    ///
253    /// Parameters are comma-separated. `event=<TYPE>` is required,
254    /// `data=<JSON>` is optional, and all other entries enter the
255    /// identifier map. Use `key:=JSON` for explicitly typed JSON values.
256    Notify {
257        /// Comma-separated parameters, for example
258        /// `event=mars,count:=12,class=od,data={"x":1}`.
259        parameters: String,
260        /// Supplement positional identifiers: key=value is an exact string;
261        /// key:=JSON is explicitly typed. Repeatable; duplicate keys are errors.
262        #[arg(long, value_name = "KEY=VALUE", action = clap::ArgAction::Append)]
263        identifier: Vec<String>,
264    },
265
266    /// Run one or more listeners against /api/v1/watch.
267    ///
268    /// Listeners come from the positional YAML files (each carrying
269    /// its own top-level `listeners:` list) when supplied, OR from
270    /// the `listeners:` section of the global config when not.
271    /// Spawns every resolved listener concurrently; a single
272    /// listener's error WARNs but does not cancel siblings.
273    Listen {
274        /// Listener YAML files. Each file's `listeners:` list is
275        /// concatenated in argv order; positional files REPLACE
276        /// (not merge with) the global config's `listeners:`
277        /// section for this invocation. Ignored when `--event` and
278        /// an identifier source are supplied (inline mode takes
279        /// precedence, matching `aviso replay`).
280        listener_files: Vec<PathBuf>,
281
282        /// Force MemoryStore for the invocation. Ignores any
283        /// configured `state_file`.
284        #[arg(long)]
285        no_state_store: bool,
286
287        /// Budget until the first confirmed Aviso stream, across retries.
288        /// Use 0s to disable. Does not limit a healthy stream's lifetime.
289        #[arg(long, value_name = "DURATION", default_value = "30s", value_parser = humantime::parse_duration, allow_hyphen_values = true)]
290        startup_timeout: std::time::Duration,
291
292        /// Listener-level cursor override applied uniformly to every
293        /// resolved listener. Accepts the same seven forms as
294        /// `aviso replay --from`. When set, the listener's per-YAML
295        /// `from_id` / `from_date` is overridden.
296        #[arg(long, value_name = "VALUE")]
297        from: Option<String>,
298
299        #[command(flatten)]
300        inline: identifiers::InlineListenerArgs,
301    },
302
303    /// Replay historical notifications from a server-side cursor.
304    Replay {
305        /// Listener name from the resolved listener set. Required
306        /// when more than one listener resolves.
307        #[arg(long, value_name = "NAME")]
308        listener: Option<String>,
309
310        #[command(flatten)]
311        inline: identifiers::InlineListenerArgs,
312
313        /// Required cursor. Accepts a u64 sequence id OR one of
314        /// six date forms; see the '`--from` value formats' section
315        /// at <https://github.com/ecmwf/aviso-client/blob/main/docs/src/cli/configuration.md>
316        /// for the full list and the pure-digit-always-id ambiguity rule.
317        #[arg(long, value_name = "VALUE", required = true)]
318        from: String,
319
320        /// Listener YAML files. Same resolution semantics as
321        /// `aviso listen`.
322        listener_files: Vec<PathBuf>,
323    },
324
325    /// Schema operations.
326    #[command(subcommand)]
327    Schema(SchemaSubcommand),
328
329    /// Destructive admin operations. Each leaf requires --yes.
330    #[command(subcommand)]
331    Admin(AdminSubcommand),
332
333    /// Configuration introspection.
334    #[command(subcommand)]
335    Config(ConfigSubcommand),
336
337    /// Print shell completions for the chosen shell to stdout.
338    Completions {
339        /// Target shell. One of: bash, zsh, fish, powershell,
340        /// elvish.
341        shell: clap_complete::Shell,
342    },
343}
344
345#[derive(Debug, Subcommand)]
346enum SchemaSubcommand {
347    /// List all schemas registered on the server.
348    List,
349    /// Get the schema for one event type.
350    Get {
351        /// Event type whose schema to fetch.
352        event_type: String,
353    },
354}
355
356#[derive(Debug, Subcommand)]
357enum AdminSubcommand {
358    /// Wipe every notification for one event-type stream.
359    WipeStream {
360        /// Event type whose stream to wipe.
361        event_type: String,
362        /// Required confirmation. Without it the command exits 2
363        /// with usage.
364        #[arg(long)]
365        yes: bool,
366    },
367    /// Wipe every notification across every stream.
368    WipeAll {
369        /// Required confirmation. Without it the command exits 2
370        /// with usage.
371        #[arg(long)]
372        yes: bool,
373    },
374    /// Delete a single notification by its CloudEvents id
375    /// (`<event_type>@<sequence>`).
376    Delete {
377        /// CloudEvents id of the notification to delete.
378        notification_id: String,
379        /// Required confirmation. Without it the command exits 2
380        /// with usage.
381        #[arg(long)]
382        yes: bool,
383    },
384}
385
386#[derive(Debug, Subcommand)]
387enum ConfigSubcommand {
388    /// Dump the resolved config (flag-over-env-over-file applied)
389    /// to stdout.
390    Dump {
391        /// Mask tokens and passwords in the output.
392        #[arg(long)]
393        redact: bool,
394    },
395}
396
397fn init_tracing(verbose: u8, ansi: bool) -> Result<()> {
398    use std::io::IsTerminal as _;
399    use tracing_subscriber::EnvFilter;
400    use tracing_subscriber::filter::LevelFilter;
401    use tracing_subscriber::fmt;
402
403    // Filter policy is per-crate. The CLI binary and the core
404    // library both compile under the crate name `aviso` (the
405    // binary's `[[bin]] name = "aviso"` makes its module_path
406    // resolve to `aviso`, same as the lib), so a single `aviso`
407    // directive covers both. Every other crate (hyper, h2,
408    // reqwest, rustls, etc.) stays at WARN regardless of -v so
409    // the operator does not get flooded with HTTP/2 frame logs
410    // when they asked for "a bit more detail from aviso". Power
411    // users who want transport diagnostics set `AVISO_LOG`
412    // explicitly (e.g. `AVISO_LOG=h2=debug,hyper=debug,aviso=debug`),
413    // and that operator-supplied directive overrides -v entirely.
414    let our_level = match verbose {
415        0 => "info",
416        1 => "debug",
417        _ => "trace",
418    };
419    let filter = if let Ok(directives) = std::env::var("AVISO_LOG") {
420        EnvFilter::builder()
421            .with_default_directive(LevelFilter::WARN.into())
422            .parse_lossy(directives)
423    } else {
424        let directive_str = format!("warn,aviso={our_level}");
425        EnvFilter::try_new(directive_str).context("constructing default tracing filter")?
426    };
427
428    // Output format is TTY-aware. Interactive operators see a
429    // compact human-readable line per event (colored only when the
430    // operator opts in via `--color auto|always`, off by default);
431    // headless deployments (piped stderr, systemd, CI) get OTel-JSON
432    // for log aggregators (never colored regardless of the flag).
433    // Detection is on stderr (not stdout) so the common
434    // `aviso listen | tee log.txt` pattern correctly keeps the
435    // operator's terminal human-friendly while the file gets the
436    // operator's chosen trigger output.
437    // `try_init` returns Err only when a global subscriber is already
438    // installed. That happens when `run` is called more than once in a single
439    // process: the test suite calls `_run_cli` repeatedly, and a host program
440    // embedding the extension could too. A failed install is treated as
441    // success there, leaving the first subscriber in place.
442    if std::io::stderr().is_terminal() {
443        let _ = fmt()
444            .with_env_filter(filter)
445            .with_writer(std::io::stderr)
446            .with_target(false)
447            .with_timer(tracing_format::ShortClockTimer)
448            .with_ansi(ansi)
449            .compact()
450            .try_init();
451    } else {
452        let _ = fmt()
453            .with_env_filter(filter)
454            .with_writer(std::io::stderr)
455            .event_format(tracing_format::OtelLogFormat::new())
456            .fmt_fields(tracing_subscriber::fmt::format::JsonFields::new())
457            .try_init();
458    }
459
460    Ok(())
461}
462
463async fn dispatch(cli: Cli) -> Result<()> {
464    // Completions need no configuration and make no request, so they run
465    // before anything is read from disk or the environment. A broken config
466    // or credentials file must not stop a shell from installing them.
467    if let Commands::Completions { shell } = cli.command {
468        return commands::completions::run(shell);
469    }
470    dispatch_configured(cli).await
471}
472
473/// Runs every command that needs the resolved configuration.
474async fn dispatch_configured(cli: Cli) -> Result<()> {
475    let resolved = config::resolve(
476        cli.config.as_ref(),
477        cli.state_file.as_ref(),
478        cli.base_url.as_deref(),
479        cli.token.as_deref(),
480        cli.username.as_deref(),
481        cli.password.as_deref(),
482        &cli.ca_bundle,
483        cli.danger_accept_invalid_certs,
484        cli.json,
485        cli.verbose,
486    )?;
487
488    if resolved.tls_danger_accept_invalid_certs.value {
489        tracing::warn!(
490            event.name = "cli.tls.insecure_mode",
491            "TLS certificate validation disabled by --danger-accept-invalid-certs; do not use in production"
492        );
493    }
494
495    tracing::debug!(
496        event.name = "cli.config.resolved",
497        config_path = %resolved.config_path.value.display(),
498        state_path = %resolved.state_path.value.display(),
499        base_url_set = resolved.base_url.is_some(),
500        auth_provider_set = resolved.auth_provider.is_some(),
501        listeners_count = resolved.listeners.len(),
502        "resolved configuration"
503    );
504
505    match cli.command {
506        Commands::Notify {
507            parameters,
508            identifier,
509        } => commands::notify::run(&resolved, &parameters, &identifier).await,
510        Commands::Listen {
511            listener_files,
512            no_state_store,
513            startup_timeout,
514            from,
515            inline,
516        } => {
517            commands::listen::run(
518                &resolved,
519                &listener_files,
520                no_state_store,
521                from.as_deref(),
522                inline.resolve()?,
523                startup_timeout,
524            )
525            .await
526        }
527        Commands::Replay {
528            listener,
529            inline,
530            from,
531            listener_files,
532        } => {
533            commands::replay::run(
534                &resolved,
535                &listener_files,
536                listener.as_deref(),
537                inline.resolve()?,
538                &from,
539            )
540            .await
541        }
542        Commands::Schema(sub) => match sub {
543            SchemaSubcommand::List => commands::schema::run_list(&resolved).await,
544            SchemaSubcommand::Get { event_type } => {
545                commands::schema::run_get(&resolved, &event_type).await
546            }
547        },
548        Commands::Admin(sub) => match sub {
549            AdminSubcommand::WipeStream { event_type, yes } => {
550                if !yes {
551                    return Err(exit::usage_error("aviso admin wipe-stream requires --yes"));
552                }
553                commands::admin::run_wipe_stream(&resolved, &event_type).await
554            }
555            AdminSubcommand::WipeAll { yes } => {
556                if !yes {
557                    return Err(exit::usage_error("aviso admin wipe-all requires --yes"));
558                }
559                commands::admin::run_wipe_all(&resolved).await
560            }
561            AdminSubcommand::Delete {
562                notification_id,
563                yes,
564            } => {
565                if !yes {
566                    return Err(exit::usage_error("aviso admin delete requires --yes"));
567                }
568                commands::admin::run_delete(&resolved, &notification_id).await
569            }
570        },
571        Commands::Config(ConfigSubcommand::Dump { redact }) => {
572            commands::config_dump::run(&resolved, redact)
573        }
574        // Handled in `dispatch` before configuration is read.
575        Commands::Completions { .. } => Ok(()),
576    }
577}
578
579/// Runs the `aviso` command-line client to completion and returns the
580/// process exit code.
581///
582/// This is the single entry point shared by the `aviso` binary
583/// (`src/main.rs`) and the bundled `aviso` console command shipped in the
584/// `pyaviso` Python wheel through the `aviso-py` extension. It owns argument
585/// parsing, tracing setup, the async runtime, and the exit-code mapping, and
586/// it does not call [`std::process::exit`] on its normal paths, so an embedding
587/// process (the Python interpreter) keeps control of its own lifecycle. The one
588/// exception is the second-Ctrl+C hard exit during `listen` / `replay`, which
589/// terminates the process immediately by design.
590///
591/// `args` is the full argument vector including the program name at index 0,
592/// matching [`std::env::args_os`] and `sys.argv`.
593///
594/// Exit codes: `0` success, `1` runtime error, `2` usage error. A clap parse
595/// failure prints its message and returns clap's own exit code (`2`), while
596/// `--help` and `--version` print and return `0`.
597pub fn run<I, T>(args: I) -> i32
598where
599    I: IntoIterator<Item = T>,
600    T: Into<OsString> + Clone,
601{
602    use std::io::IsTerminal as _;
603
604    let cli = match Cli::try_parse_from(args) {
605        Ok(cli) => cli,
606        Err(err) => {
607            let _ = err.print();
608            return err.exit_code();
609        }
610    };
611    let no_color = std::env::var_os("NO_COLOR").is_some();
612    let stderr_color = color_enabled(cli.color, std::io::stderr().is_terminal(), no_color);
613    let stdout_color = color_enabled(cli.color, std::io::stdout().is_terminal(), no_color);
614    aviso::set_echo_color_enabled(stdout_color);
615    if let Err(e) = init_tracing(cli.verbose, stderr_color) {
616        let _ = output::write_stderr_line(&format!("error: failed to initialise tracing: {e:#}"));
617        return exit::RUNTIME_ERROR;
618    }
619    let runtime = match tokio::runtime::Builder::new_multi_thread()
620        .enable_all()
621        .build()
622    {
623        Ok(runtime) => runtime,
624        Err(e) => {
625            let _ =
626                output::write_stderr_line(&format!("error: failed to start async runtime: {e:#}"));
627            return exit::RUNTIME_ERROR;
628        }
629    };
630    match runtime.block_on(dispatch(cli)) {
631        Ok(()) => exit::SUCCESS,
632        Err(e) => {
633            let code = exit::exit_code_for_anyhow(&e);
634            error::format_chain(&e);
635            code
636        }
637    }
638}