aviso-cli 2.0.0

Command-line client for aviso-server.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
// (C) Copyright 2024- ECMWF and individual contributors.
//
// This software is licensed under the terms of the Apache Licence Version 2.0
// which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
// In applying this licence, ECMWF does not waive the privileges and immunities
// granted to it by virtue of its status as an intergovernmental organisation nor
// does it submit to any jurisdiction.

//! Configuration model for the `aviso` binary.
//!
//! Two responsibilities:
//!
//! 1. Deserialise the YAML config file (typically
//!    `~/.config/aviso/config.yaml`) into a typed [`ConfigFile`].
//! 2. Materialise a [`Resolved`] by walking flag, env, and file
//!    layers per Q3's per-field precedence (`flag > env > file >
//!    default`).
//!
//! The CLI never calls `aviso::auth::ConfigFile::from_path`: the
//! library helper parses an auth-only YAML shape, not the locked
//! Q3 nested-under-`auth:` block. The CLI parses the auth block
//! itself (see [`AuthConfig`]) and constructs `aviso::auth::Bearer`
//! or `aviso::auth::Basic` from the parsed values.
//!
//! The `auth:` section is OPTIONAL. A config file with no `auth:`
//! block is fine; the resolved auth chain falls back to env, flag,
//! or no auth at all (anonymous access to schema / health endpoints).

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;

use anyhow::{Context, Result};
use aviso::auth::AuthProvider;
use serde::Deserialize;
use serde_norway as yaml;

use crate::auth as cli_auth;
use crate::exit::usage_error;
use crate::paths;

/// YAML schema for the CLI's config file.
///
/// `#[serde(deny_unknown_fields)]` catches misspelled top-level keys
/// loudly so the operator sees a clear "unknown field" error rather
/// than the value being silently ignored.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ConfigFile {
    #[serde(default)]
    pub(crate) base_url: Option<String>,
    #[serde(default)]
    pub(crate) auth: Option<AuthConfig>,
    #[serde(default, with = "humantime_serde::option")]
    pub(crate) timeout: Option<Duration>,
    #[serde(default, with = "humantime_serde::option")]
    pub(crate) heartbeat_interval: Option<Duration>,
    #[serde(default)]
    pub(crate) state_file: Option<PathBuf>,
    #[serde(default)]
    pub(crate) tls: Option<TlsConfig>,
    #[serde(default)]
    pub(crate) listeners: Vec<ListenerSpec>,
}

/// `auth:` block. The two flavours are mutually exclusive at the
/// schema level: the operator picks either `bearer_token` OR `basic`,
/// never both.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct AuthConfig {
    #[serde(default)]
    pub(crate) bearer_token: Option<String>,
    #[serde(default)]
    pub(crate) basic: Option<BasicAuthConfig>,
}

/// `auth.basic:` block.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct BasicAuthConfig {
    pub(crate) username: String,
    pub(crate) password: String,
}

/// `tls:` block.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct TlsConfig {
    #[serde(default)]
    pub(crate) ca_bundle: Vec<PathBuf>,
    #[serde(default)]
    pub(crate) danger_accept_invalid_certs: bool,
}

/// A single listener entry under top-level `listeners:`.
///
/// The shape is pyaviso-compatible with one rename: pyaviso's
/// `request:` key is `identifiers:` here per Amendment B (matching
/// the lib's `Notification::identifier` field name).
///
/// Triggers reuse the lib's `TriggerConfig` deserialiser so all six
/// shipped trigger kinds (echo, log, command, webhook, teams, post)
/// are available from YAML unchanged.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ListenerSpec {
    #[serde(default)]
    pub(crate) name: Option<String>,
    pub(crate) event: String,
    #[serde(default)]
    pub(crate) identifiers: BTreeMap<String, serde_json::Value>,
    #[serde(default)]
    pub(crate) from_id: Option<u64>,
    #[serde(default)]
    pub(crate) from_date: Option<String>,
    #[serde(default)]
    pub(crate) triggers: Vec<aviso::watch::TriggerConfig>,
}

/// Source tag attached to every field in [`Resolved`] so the
/// `config dump` subcommand can attribute each value to its origin.
/// The tag tracks ONLY the precedence layer that supplied the value;
/// it carries no payload of its own.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Source {
    /// Value came from a command-line flag.
    Flag,
    /// Value came from an environment variable.
    Env,
    /// Value came from the config file.
    File,
    /// Value came from a built-in default.
    Default,
}

/// One layered value plus its source tag.
#[derive(Debug, Clone)]
pub(crate) struct Sourced<T> {
    pub(crate) value: T,
    pub(crate) source: Source,
}

/// The materialised configuration walked through every layer.
///
/// `resolve` builds this once at CLI startup; subcommand handlers
/// consume the resolved values. The `auth_provider` is already
/// chain-composed per Q8 + amendment A2; the TLS knobs feed into
/// the `AvisoClientBuilder` setters `.ca_bundle` and
/// `.danger_accept_invalid_certs`. Each path is rendered absolute
/// per Error UX rule 3.
#[derive(Debug, Clone)]
pub(crate) struct Resolved {
    pub(crate) config_path: Sourced<PathBuf>,
    pub(crate) state_path: Sourced<PathBuf>,
    pub(crate) base_url: Option<Sourced<String>>,
    pub(crate) timeout: Option<Sourced<Duration>>,
    pub(crate) heartbeat_interval: Option<Sourced<Duration>>,
    pub(crate) tls_ca_bundle_paths: Sourced<Vec<PathBuf>>,
    pub(crate) tls_danger_accept_invalid_certs: Sourced<bool>,
    pub(crate) auth_provider: Option<Arc<dyn AuthProvider>>,
    pub(crate) listeners: Vec<ListenerSpec>,
    pub(crate) force_json: bool,
    pub(crate) verbose: u8,
}

/// Walks the precedence layers and produces a [`Resolved`].
///
/// The `cli_*` arguments come from the parsed clap `Cli`. The
/// environment is read via [`read_env`], which surfaces a usage
/// error (exit 2) when an `AVISO_*` env var is set but its value
/// is not valid UTF-8 rather than silently falling back to a
/// lower-precedence layer.
///
/// # Errors
///
/// - Bad YAML in the config file (parse error with file
///   `<line>:<col>` location surfaced via the `serde_norway`
///   `Display` impl).
/// - Invalid auth combination (empty `--token`, etc.).
/// - Non-UTF-8 value in any `AVISO_*` env var (surfaced as a usage
///   error naming the offending variable).
/// - Home directory not resolvable AND no `--config` / env override
///   supplied (rare; needs to be a deliberate environment for the
///   home-dir lookup to fail).
#[allow(
    clippy::too_many_arguments,
    reason = "the resolver takes one argument per layered field; bundling them into a struct would only add a one-off type with no further consumers"
)]
pub(crate) fn resolve(
    cli_config: Option<&PathBuf>,
    cli_state_file: Option<&PathBuf>,
    cli_base_url: Option<&str>,
    cli_token: Option<&str>,
    cli_username: Option<&str>,
    cli_password: Option<&str>,
    cli_ca_bundle: &[PathBuf],
    cli_danger_accept_invalid_certs: bool,
    cli_force_json: bool,
    cli_verbose: u8,
) -> Result<Resolved> {
    let env_config_path = read_env("AVISO_CLIENT_CONFIG_FILE")?;
    let env_state_path = read_env("AVISO_STATE_FILE")?;
    let env_base_url = read_env("AVISO_BASE_URL")?;

    let config_path = {
        let value = paths::resolve_config_path(cli_config, env_config_path.as_deref())?;
        let source = if cli_config.is_some() {
            Source::Flag
        } else if env_config_path.is_some() {
            Source::Env
        } else {
            Source::Default
        };
        Sourced { value, source }
    };
    let file = load_optional(&config_path.value)
        .with_context(|| format!("at: {}", config_path.value.display()))?;

    let state_path = if let Some(p) = cli_state_file {
        Sourced {
            value: paths::resolve_state_path(Some(p), None)?,
            source: Source::Flag,
        }
    } else if let Some(s) = env_state_path.as_deref() {
        Sourced {
            value: paths::resolve_state_path(None, Some(s))?,
            source: Source::Env,
        }
    } else if let Some(p) = file.state_file.as_ref() {
        Sourced {
            value: paths::resolve_state_path(Some(p), None)?,
            source: Source::File,
        }
    } else {
        Sourced {
            value: paths::resolve_state_path(None, None)?,
            source: Source::Default,
        }
    };

    let base_url = cli_base_url
        .map(|s| Sourced {
            value: s.to_string(),
            source: Source::Flag,
        })
        .or_else(|| {
            env_base_url.clone().map(|s| Sourced {
                value: s,
                source: Source::Env,
            })
        })
        .or_else(|| {
            file.base_url.clone().map(|s| Sourced {
                value: s,
                source: Source::File,
            })
        });

    let timeout = file.timeout.map(|v| Sourced {
        value: v,
        source: Source::File,
    });
    let heartbeat_interval = file.heartbeat_interval.map(|v| Sourced {
        value: v,
        source: Source::File,
    });

    let (tls_ca_bundle_paths, tls_danger_accept_invalid_certs) = resolve_tls(
        cli_ca_bundle,
        cli_danger_accept_invalid_certs,
        file.tls.as_ref(),
    )?;

    let flag_provider = cli_auth::provider_from_flags(cli_token, cli_username, cli_password)?;
    let env_provider = cli_auth::provider_from_env()?;
    let file_provider = cli_auth::provider_from_file(file.auth.as_ref())?;
    let auth_provider = cli_auth::build_chain(flag_provider, env_provider, file_provider);

    Ok(Resolved {
        config_path,
        state_path,
        base_url,
        timeout,
        heartbeat_interval,
        tls_ca_bundle_paths,
        tls_danger_accept_invalid_certs,
        auth_provider,
        listeners: file.listeners,
        force_json: cli_force_json,
        verbose: cli_verbose,
    })
}

fn resolve_tls(
    cli_ca_bundle: &[PathBuf],
    cli_danger: bool,
    file_tls: Option<&TlsConfig>,
) -> Result<(Sourced<Vec<PathBuf>>, Sourced<bool>)> {
    let ca_bundle = if !cli_ca_bundle.is_empty() {
        Sourced {
            value: absolutize_all(cli_ca_bundle)?,
            source: Source::Flag,
        }
    } else if let Some(tls) = file_tls {
        // The presence of a `tls:` block in the config IS a
        // statement of intent from the operator; tag the bundle as
        // File regardless of whether the list is empty so
        // `config dump` source attribution reflects the layer that
        // actually supplied the value rather than the bundle's
        // emptiness.
        Sourced {
            value: absolutize_all(&tls.ca_bundle)?,
            source: Source::File,
        }
    } else {
        Sourced {
            value: Vec::new(),
            source: Source::Default,
        }
    };

    let danger = if cli_danger {
        Sourced {
            value: true,
            source: Source::Flag,
        }
    } else if let Some(tls) = file_tls {
        Sourced {
            value: tls.danger_accept_invalid_certs,
            source: Source::File,
        }
    } else {
        Sourced {
            value: false,
            source: Source::Default,
        }
    };

    Ok((ca_bundle, danger))
}

/// Renders every path in `paths_in` absolute via [`paths::absolutize`]
/// so subsequent error messages quote absolute paths per the Error
/// UX rule 3 convention regardless of whether the operator supplied
/// relative or absolute inputs.
fn absolutize_all(paths_in: &[PathBuf]) -> Result<Vec<PathBuf>> {
    paths_in.iter().map(|p| paths::absolutize(p)).collect()
}

fn read_env(name: &str) -> Result<Option<String>> {
    match std::env::var(name) {
        Ok(v) if !v.is_empty() => Ok(Some(v)),
        Ok(_) | Err(std::env::VarError::NotPresent) => Ok(None),
        Err(std::env::VarError::NotUnicode(raw)) => Err(usage_error(format!(
            "env var {name} is set but its value is not valid UTF-8 ({raw:?}); set a UTF-8 value or unset the variable"
        ))),
    }
}

/// Loads and parses `path` as a `ConfigFile`.
///
/// Returns `Ok(ConfigFile::default())` when `path` does not exist
/// (operating without a config file is supported; flag and env
/// overrides cover the common case). All other I/O errors and any
/// YAML parse error surface verbatim.
pub(crate) fn load_optional(path: &Path) -> Result<ConfigFile> {
    if !path.exists() {
        return Ok(ConfigFile::default());
    }
    let bytes =
        std::fs::read(path).with_context(|| format!("read config file: {}", path.display()))?;
    let cfg: ConfigFile = yaml::from_slice(&bytes)
        .with_context(|| format!("parse config file: {}", path.display()))?;
    Ok(cfg)
}

#[cfg(test)]
#[allow(
    clippy::unwrap_used,
    clippy::expect_used,
    reason = "test code: unwrap/expect on yaml round-trip is the expected diagnostic"
)]
mod tests {
    use super::*;

    fn parse(yaml_text: &str) -> ConfigFile {
        yaml::from_str(yaml_text).expect("test YAML should parse")
    }

    #[test]
    fn parse_empty_yaml_yields_defaults() {
        let cfg = parse("");
        assert!(cfg.base_url.is_none());
        assert!(cfg.auth.is_none());
        assert!(cfg.listeners.is_empty());
    }

    #[test]
    fn parse_full_config_round_trip() {
        let yaml_text = r#"
base_url: "https://aviso.example.org"
auth:
  bearer_token: "secret"
timeout: 30s
heartbeat_interval: 30s
state_file: /var/lib/aviso/state.json
tls:
  danger_accept_invalid_certs: false
listeners:
  - name: mars-od
    event: mars
    identifiers:
      class: od
      stream: oper
    triggers:
      - type: echo
"#;
        let cfg = parse(yaml_text);
        assert_eq!(cfg.base_url.as_deref(), Some("https://aviso.example.org"));
        assert!(cfg.auth.is_some());
        let auth = cfg.auth.unwrap();
        assert_eq!(auth.bearer_token.as_deref(), Some("secret"));
        assert!(auth.basic.is_none());
        assert_eq!(cfg.timeout, Some(Duration::from_secs(30)));
        assert_eq!(cfg.heartbeat_interval, Some(Duration::from_secs(30)));
        assert_eq!(
            cfg.state_file,
            Some(PathBuf::from("/var/lib/aviso/state.json"))
        );
        let tls = cfg.tls.expect("tls block present");
        assert!(!tls.danger_accept_invalid_certs);
        assert!(tls.ca_bundle.is_empty());
        assert_eq!(cfg.listeners.len(), 1);
        let listener = &cfg.listeners[0];
        assert_eq!(listener.name.as_deref(), Some("mars-od"));
        assert_eq!(listener.event, "mars");
        assert_eq!(listener.identifiers.len(), 2);
        assert_eq!(listener.triggers.len(), 1);
    }

    #[test]
    fn parse_nested_auth_basic() {
        let yaml_text = r"
auth:
  basic:
    username: alice
    password: hunter2
";
        let cfg = parse(yaml_text);
        let auth = cfg.auth.expect("auth present");
        assert!(auth.bearer_token.is_none());
        let basic = auth.basic.expect("basic present");
        assert_eq!(basic.username, "alice");
        assert_eq!(basic.password, "hunter2");
    }

    #[test]
    fn parse_rejects_unknown_top_level_field() {
        let err = yaml::from_str::<ConfigFile>("bogus_key: 1").unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("bogus_key") || msg.contains("unknown field"),
            "error should name the bad field: {msg}"
        );
    }

    #[test]
    fn parse_rejects_unknown_field_inside_auth() {
        let err = yaml::from_str::<ConfigFile>("auth:\n  bogus_key: 1\n").unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("bogus_key") || msg.contains("unknown field"),
            "error should name the bad field: {msg}"
        );
    }

    #[test]
    fn load_optional_returns_default_when_file_absent() {
        let cfg = load_optional(Path::new("/tmp/this-path-does-not-exist-aviso-test")).unwrap();
        assert!(cfg.base_url.is_none());
    }

    #[test]
    fn listeners_list_with_identifiers_field_name() {
        let yaml_text = r"
listeners:
  - event: mars
    identifiers:
      class: od
";
        let cfg = parse(yaml_text);
        assert_eq!(cfg.listeners.len(), 1);
        let l = &cfg.listeners[0];
        assert_eq!(l.event, "mars");
        assert_eq!(l.identifiers.len(), 1);
    }

    #[test]
    fn resolve_tls_absolutizes_relative_cli_ca_bundle_paths() {
        let rel = PathBuf::from("aviso-test-relative-flag-ca.pem");
        let (bundle, _) = resolve_tls(std::slice::from_ref(&rel), false, None).unwrap();
        assert_eq!(bundle.source, Source::Flag);
        assert_eq!(bundle.value.len(), 1);
        assert!(
            bundle.value[0].is_absolute(),
            "CA bundle path supplied via flag should be absolutized so error messages quote absolute paths; got {}",
            bundle.value[0].display()
        );
        assert!(
            bundle.value[0].ends_with("aviso-test-relative-flag-ca.pem"),
            "file name should be preserved; got {}",
            bundle.value[0].display()
        );
    }

    #[test]
    fn resolve_tls_absolutizes_relative_file_ca_bundle_paths() {
        let rel = PathBuf::from("aviso-test-relative-file-ca.pem");
        let tls = TlsConfig {
            ca_bundle: vec![rel.clone()],
            danger_accept_invalid_certs: false,
        };
        let (bundle, _) = resolve_tls(&[], false, Some(&tls)).unwrap();
        assert_eq!(bundle.source, Source::File);
        assert_eq!(bundle.value.len(), 1);
        assert!(
            bundle.value[0].is_absolute(),
            "CA bundle path supplied via file should be absolutized; got {}",
            bundle.value[0].display()
        );
    }

    #[test]
    fn resolve_tls_passes_absolute_ca_bundle_paths_through_unchanged() {
        let abs = PathBuf::from("/tmp/aviso-test-already-absolute.pem");
        let (bundle, _) = resolve_tls(std::slice::from_ref(&abs), false, None).unwrap();
        assert_eq!(bundle.value, vec![abs]);
    }
}