Skip to main content

opssignal_core/
config.rs

1//! `signal.yaml` loading.
2//!
3//! The file is resolved from `$SIGNAL_CONFIG` first (a local path, or an
4//! `https://`, `s3://`, `gs://` or `az://` URL; see [`crate::source`]), then
5//! `./signal.yaml`.
6//! If neither exists the SDK still works: it falls back to the
7//! zero-config default (console provider, SQLite sink at
8//! `./signals.db`), so `notify()` never needs setup to do something
9//! useful.
10//!
11//! `${VAR}` placeholders in string values are replaced from the
12//! environment so secrets like Slack webhook URLs stay out of the file.
13//! A placeholder whose variable is unset is a config error, not an
14//! empty string: a blank webhook URL would only fail later, at delivery
15//! time, which is exactly when nobody is looking.
16
17use crate::routing::{Route, RoutingConfig};
18use crate::signal::SignalError;
19use crate::source::{self, ConfigSource, LoadOptions};
20use crate::template::{TemplateConfig, Templates};
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23use std::path::Path;
24
25pub const CONFIG_ENV_VAR: &str = "SIGNAL_CONFIG";
26pub const DEFAULT_CONFIG_FILE: &str = "signal.yaml";
27pub const DEFAULT_SQLITE_PATH: &str = "./signals.db";
28
29#[derive(Debug, Clone, Default, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct Config {
32    #[serde(default)]
33    pub providers: ProvidersConfig,
34    #[serde(default)]
35    pub routes: Vec<Route>,
36    #[serde(default)]
37    pub storage: StorageConfig,
38}
39
40/// One file of a layered config: what it sets itself, plus the files
41/// it builds on. [`Config::from_layers`] merges a chain of these.
42#[derive(Debug, Clone, Default, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct Layer {
45    /// Parent files, as URLs or paths relative to this file. Each
46    /// parent is merged in before this file, so this file wins.
47    #[serde(default, deserialize_with = "one_or_many")]
48    pub extends: Vec<String>,
49    #[serde(default)]
50    pub providers: ProvidersConfig,
51    #[serde(default)]
52    pub routes: Vec<Route>,
53    #[serde(default)]
54    pub storage: Option<StorageConfig>,
55}
56
57fn one_or_many<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
58    #[derive(Deserialize)]
59    #[serde(untagged)]
60    enum OneOrMany {
61        One(String),
62        Many(Vec<String>),
63    }
64    Ok(match OneOrMany::deserialize(d)? {
65        OneOrMany::One(s) => vec![s],
66        OneOrMany::Many(v) => v,
67    })
68}
69
70impl Layer {
71    /// Parses one file, interpolating `${VAR}` placeholders. Routes are
72    /// checked only once the whole chain is merged, since a team file
73    /// may route to a provider its parent configures.
74    ///
75    /// A `${store:...}` secret placeholder is an error here, since it
76    /// needs a network read; configs loaded through a client resolve
77    /// them first (see [`crate::secrets`]).
78    pub fn from_yaml_str(text: &str) -> Result<Self, SignalError> {
79        Self::from_yaml_str_with(text, &HashMap::new())
80    }
81
82    /// Like [`Layer::from_yaml_str`], taking secret placeholders from
83    /// `secrets` (keyed by placeholder name, such as `aws-sm:prod/x`).
84    pub(crate) fn from_yaml_str_with(
85        text: &str,
86        secrets: &HashMap<String, String>,
87    ) -> Result<Self, SignalError> {
88        let mut value = parse_value(text)?;
89        interpolate(&mut value, secrets)?;
90        serde_yaml::from_value(value).map_err(|e| config_err(e.to_string()))
91    }
92
93    /// The names of the secret placeholders in `text`, each once, in
94    /// order. Placeholders in YAML comments are not included.
95    pub(crate) fn secret_refs(text: &str) -> Result<Vec<String>, SignalError> {
96        fn walk(value: &serde_yaml::Value, out: &mut Vec<String>) -> Result<(), SignalError> {
97            match value {
98                serde_yaml::Value::String(s) => {
99                    for name in placeholders(s)? {
100                        if crate::secrets::is_ref(name) && !out.iter().any(|n| n == name) {
101                            out.push(name.to_string());
102                        }
103                    }
104                }
105                serde_yaml::Value::Sequence(items) => {
106                    for item in items {
107                        walk(item, out)?;
108                    }
109                }
110                serde_yaml::Value::Mapping(map) => {
111                    for (_, v) in map {
112                        walk(v, out)?;
113                    }
114                }
115                serde_yaml::Value::Tagged(tagged) => walk(&tagged.value, out)?,
116                _ => {}
117            }
118            Ok(())
119        }
120        let mut out = Vec::new();
121        walk(&parse_value(text)?, &mut out)?;
122        Ok(out)
123    }
124}
125
126fn parse_value(text: &str) -> Result<serde_yaml::Value, SignalError> {
127    let value: serde_yaml::Value =
128        serde_yaml::from_str(text).map_err(|e| config_err(format!("invalid YAML: {e}")))?;
129    // An empty file parses as null; treat it as "all defaults".
130    Ok(if value.is_null() {
131        serde_yaml::Value::Mapping(Default::default())
132    } else {
133        value
134    })
135}
136
137/// Typed per provider, with unknown keys rejected, so a typo like
138/// `slak:` is a load-time error rather than a silently missing provider.
139#[derive(Debug, Clone, Default, Deserialize)]
140#[serde(deny_unknown_fields)]
141pub struct ProvidersConfig {
142    /// Accepted for readability of config files, but the console
143    /// provider is always registered: it is the routing fallback and
144    /// cannot be turned off.
145    #[serde(default)]
146    pub console: Option<ConsoleConfig>,
147    #[serde(default)]
148    pub slack: Option<SlackConfig>,
149    #[serde(default)]
150    pub webhook: Option<WebhookConfig>,
151    #[serde(default)]
152    pub pagerduty: Option<PagerDutyConfig>,
153    #[serde(default)]
154    pub teams: Option<TeamsConfig>,
155    #[serde(default)]
156    pub opsgenie: Option<OpsgenieConfig>,
157    #[serde(default)]
158    pub email: Option<EmailConfig>,
159}
160
161impl ProvidersConfig {
162    /// Compiles every provider's templates once, so a mistake in one is
163    /// a load-time error rather than a fallback at send time.
164    fn check_templates(&self) -> Result<(), SignalError> {
165        let all: [(&'static str, Option<&TemplateConfig>); 7] = [
166            ("console", self.console.as_ref().map(|c| &c.template)),
167            ("slack", self.slack.as_ref().map(|c| &c.template)),
168            ("webhook", self.webhook.as_ref().map(|c| &c.template)),
169            ("pagerduty", self.pagerduty.as_ref().map(|c| &c.template)),
170            ("teams", self.teams.as_ref().map(|c| &c.template)),
171            ("opsgenie", self.opsgenie.as_ref().map(|c| &c.template)),
172            ("email", self.email.as_ref().map(|c| &c.template)),
173        ];
174        for (name, template) in all {
175            if let Some(template) = template {
176                Templates::compile(name, template)?;
177            }
178        }
179        Ok(())
180    }
181}
182
183#[derive(Debug, Clone, Default, Deserialize)]
184#[serde(deny_unknown_fields)]
185pub struct ConsoleConfig {
186    #[serde(default)]
187    pub enabled: Option<bool>,
188    /// Replaces parts of the message; see [`crate::template`].
189    #[serde(default)]
190    pub template: TemplateConfig,
191}
192
193#[derive(Debug, Clone, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct SlackConfig {
196    pub webhook_url: String,
197    /// Replaces parts of the message; see [`crate::template`].
198    #[serde(default)]
199    pub template: TemplateConfig,
200}
201
202#[derive(Debug, Clone, Deserialize)]
203#[serde(deny_unknown_fields)]
204pub struct WebhookConfig {
205    pub url: String,
206    /// Replaces parts of the message; see [`crate::template`].
207    #[serde(default)]
208    pub template: TemplateConfig,
209}
210
211#[derive(Debug, Clone, Deserialize)]
212#[serde(deny_unknown_fields)]
213pub struct PagerDutyConfig {
214    /// Integration key of an Events API v2 integration on a service.
215    pub routing_key: String,
216    /// Events API endpoint; defaults to the US one. EU accounts set
217    /// `https://events.eu.pagerduty.com/v2/enqueue`.
218    #[serde(default)]
219    pub url: Option<String>,
220    /// Replaces parts of the message; see [`crate::template`].
221    #[serde(default)]
222    pub template: TemplateConfig,
223}
224
225#[derive(Debug, Clone, Deserialize)]
226#[serde(deny_unknown_fields)]
227pub struct TeamsConfig {
228    /// Workflows (or legacy incoming) webhook URL of a Teams channel.
229    pub webhook_url: String,
230    /// Replaces parts of the message; see [`crate::template`].
231    #[serde(default)]
232    pub template: TemplateConfig,
233}
234
235#[derive(Debug, Clone, Deserialize)]
236#[serde(deny_unknown_fields)]
237pub struct OpsgenieConfig {
238    /// Key of an API integration on an Opsgenie team.
239    pub api_key: String,
240    /// Alert API endpoint; defaults to the US one. EU accounts set
241    /// `https://api.eu.opsgenie.com/v2/alerts`.
242    #[serde(default)]
243    pub url: Option<String>,
244    /// Added to every alert, before the signal's severity and
245    /// environment.
246    #[serde(default)]
247    pub tags: Vec<String>,
248    /// Replaces parts of the message; see [`crate::template`].
249    #[serde(default)]
250    pub template: TemplateConfig,
251}
252
253/// Serialize as well, so a build without the AWS feature can hand the
254/// config to the `opssignal[aws]` add-on.
255#[derive(Debug, Clone, Deserialize, Serialize)]
256#[serde(deny_unknown_fields)]
257pub struct EmailConfig {
258    /// Sender, as `alerts@example.com` or `OpsSignal <alerts@example.com>`.
259    pub from: String,
260    /// At least one recipient.
261    pub to: Vec<String>,
262    #[serde(default)]
263    pub cc: Vec<String>,
264    /// Put before the subject, such as `[opssignal]`, for mail filters.
265    #[serde(default)]
266    pub subject_prefix: Option<String>,
267    /// Send through an SMTP server. Exactly one of `smtp` and `ses`.
268    #[serde(default)]
269    pub smtp: Option<SmtpConfig>,
270    /// Send through the Amazon SES API with the AWS credential chain;
271    /// needs the `aws` add-on.
272    #[serde(default)]
273    pub ses: Option<SesConfig>,
274    /// Replaces parts of the message; see [`crate::template`].
275    #[serde(default, skip_serializing_if = "TemplateConfig::is_empty")]
276    pub template: TemplateConfig,
277}
278
279/// How an [`EmailConfig`] sends.
280pub enum EmailTransport<'a> {
281    Smtp(&'a SmtpConfig),
282    Ses(&'a SesConfig),
283}
284
285impl EmailConfig {
286    /// The transport; [`EmailConfig::check`] makes sure there is
287    /// exactly one.
288    pub fn transport(&self) -> Option<EmailTransport<'_>> {
289        match (&self.smtp, &self.ses) {
290            (Some(smtp), None) => Some(EmailTransport::Smtp(smtp)),
291            (None, Some(ses)) => Some(EmailTransport::Ses(ses)),
292            _ => None,
293        }
294    }
295
296    /// Addresses are checked at load time, since a bad one would fail
297    /// every send.
298    fn check(&self) -> Result<(), SignalError> {
299        if self.to.is_empty() {
300            return Err(config_err("providers.email.to lists no recipients"));
301        }
302        let addresses = std::iter::once(&self.from).chain(&self.to).chain(&self.cc);
303        for address in addresses {
304            address.parse::<lettre::message::Mailbox>().map_err(|e| {
305                config_err(format!("providers.email: invalid address {address:?}: {e}"))
306            })?;
307        }
308        match self.transport() {
309            Some(EmailTransport::Smtp(smtp)) => {
310                if smtp.username.is_some() != smtp.password.is_some() {
311                    return Err(config_err(
312                        "providers.email.smtp needs both username and password, or neither",
313                    ));
314                }
315            }
316            Some(EmailTransport::Ses(_)) => {
317                if !crate::providers::external::cloud_supported("aws") {
318                    return Err(config_err(format!(
319                        "providers.email: {}",
320                        crate::providers::external::missing_support("email over Amazon SES", "aws")
321                    )));
322                }
323            }
324            None => {
325                return Err(config_err(
326                    "providers.email needs exactly one of smtp and ses",
327                ))
328            }
329        }
330        Ok(())
331    }
332}
333
334#[derive(Debug, Clone, Default, Deserialize, Serialize)]
335#[serde(deny_unknown_fields)]
336pub struct SesConfig {
337    /// Defaults to `AWS_REGION`, then `AWS_DEFAULT_REGION`, then
338    /// `us-east-1`.
339    #[serde(default)]
340    pub region: Option<String>,
341    /// SES configuration set, for event publishing and dedicated IPs.
342    #[serde(default)]
343    pub configuration_set: Option<String>,
344}
345
346#[derive(Debug, Clone, Deserialize, Serialize)]
347#[serde(deny_unknown_fields)]
348pub struct SmtpConfig {
349    pub host: String,
350    /// Defaults to 587 for `starttls`, 465 for `tls` and 25 for `none`.
351    #[serde(default)]
352    pub port: Option<u16>,
353    #[serde(default)]
354    pub username: Option<String>,
355    #[serde(default)]
356    pub password: Option<String>,
357    #[serde(default)]
358    pub tls: SmtpTls,
359}
360
361impl SmtpConfig {
362    pub fn port(&self) -> u16 {
363        self.port.unwrap_or(match self.tls {
364            SmtpTls::Starttls => 587,
365            SmtpTls::Tls => 465,
366            SmtpTls::None => 25,
367        })
368    }
369}
370
371/// How the SMTP connection is secured.
372#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
373#[serde(rename_all = "lowercase")]
374pub enum SmtpTls {
375    /// Plain connection upgraded with STARTTLS, which must succeed.
376    #[default]
377    Starttls,
378    /// TLS from the first byte (SMTPS).
379    Tls,
380    /// No encryption, for a relay on localhost or inside the network.
381    None,
382}
383
384#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
385#[serde(rename_all = "lowercase")]
386pub enum SinkKind {
387    #[default]
388    Sqlite,
389}
390
391#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
392#[serde(deny_unknown_fields)]
393pub struct StorageConfig {
394    #[serde(default)]
395    pub sink: SinkKind,
396    #[serde(default = "default_sqlite_path")]
397    pub path: String,
398}
399
400impl Default for StorageConfig {
401    fn default() -> Self {
402        StorageConfig {
403            sink: SinkKind::default(),
404            path: default_sqlite_path(),
405        }
406    }
407}
408
409fn default_sqlite_path() -> String {
410    DEFAULT_SQLITE_PATH.to_string()
411}
412
413impl Config {
414    /// Parses a single, self-contained config from YAML text,
415    /// interpolating `${VAR}` placeholders and checking that every route
416    /// names a configured provider. A file that uses `extends` needs to
417    /// be loaded from where it lives, with [`Config::from_file`] or
418    /// `$SIGNAL_CONFIG`, so its parents can be found.
419    pub fn from_yaml_str(text: &str) -> Result<Self, SignalError> {
420        let layer = Layer::from_yaml_str(text)?;
421        if !layer.extends.is_empty() {
422            return Err(config_err(
423                "`extends` needs the file's location; load it with Config::from_file or $SIGNAL_CONFIG",
424            ));
425        }
426        Self::from_layers([(None, layer)])
427    }
428
429    /// Merges a chain of layers, given lowest priority first, each with
430    /// the name of the file it came from (for error messages):
431    ///
432    /// - providers merge by name, and a later layer's entry replaces an
433    ///   earlier one's;
434    /// - a later layer's routes go before the earlier layers' routes,
435    ///   so the more specific file is matched first and the shared
436    ///   defaults still catch everything else;
437    /// - `storage` comes from the last layer that sets it.
438    ///
439    /// Routes are checked against the merged providers.
440    pub fn from_layers(
441        layers: impl IntoIterator<Item = (Option<String>, Layer)>,
442    ) -> Result<Self, SignalError> {
443        let mut config = Config::default();
444        // (file, index within that file) for each merged route.
445        let mut origins: Vec<(Option<String>, usize)> = Vec::new();
446        for (name, layer) in layers {
447            let p = layer.providers;
448            config.providers.console = p.console.or(config.providers.console.take());
449            config.providers.slack = p.slack.or(config.providers.slack.take());
450            config.providers.webhook = p.webhook.or(config.providers.webhook.take());
451            config.providers.pagerduty = p.pagerduty.or(config.providers.pagerduty.take());
452            config.providers.teams = p.teams.or(config.providers.teams.take());
453            config.providers.opsgenie = p.opsgenie.or(config.providers.opsgenie.take());
454            config.providers.email = p.email.or(config.providers.email.take());
455            if let Some(storage) = layer.storage {
456                config.storage = storage;
457            }
458            let mut routes = layer.routes;
459            let mut route_origins: Vec<_> = (0..routes.len()).map(|i| (name.clone(), i)).collect();
460            routes.append(&mut config.routes);
461            route_origins.append(&mut origins);
462            config.routes = routes;
463            origins = route_origins;
464        }
465        if let Some(email) = &config.providers.email {
466            email.check()?;
467        }
468        config.providers.check_templates()?;
469        config.check_routes(&origins)?;
470        Ok(config)
471    }
472
473    /// Loads and merges `sources` once, resolving secret placeholders;
474    /// see [`crate::source`].
475    pub fn load(sources: &[ConfigSource], opts: &LoadOptions) -> Result<Self, SignalError> {
476        source::load_blocking(sources, opts).map(|l| l.config)
477    }
478
479    pub fn from_file(path: &Path) -> Result<Self, SignalError> {
480        let source = ConfigSource::File(path.to_path_buf());
481        source::load_blocking(std::slice::from_ref(&source), &LoadOptions::once()).map(|l| l.config)
482    }
483
484    /// `$SIGNAL_CONFIG` if set (one or more comma-separated local
485    /// files, which must then exist, or remote URLs), else
486    /// `./signal.yaml` if present, else the zero-config default. Remote
487    /// files are fetched once, with the cache fallback described in
488    /// [`crate::source`].
489    pub fn discover() -> Result<Self, SignalError> {
490        match ConfigSource::discover()? {
491            Some(sources) => {
492                source::load_blocking(&sources, &LoadOptions::from_env()?).map(|l| l.config)
493            }
494            None => Ok(Config::default()),
495        }
496    }
497
498    pub fn routing(&self) -> RoutingConfig {
499        RoutingConfig {
500            routes: self.routes.clone(),
501        }
502    }
503
504    /// Names of the providers this config makes available. `console`
505    /// is always present because routing falls back to it.
506    pub fn provider_names(&self) -> Vec<&'static str> {
507        let mut names = vec!["console"];
508        if self.providers.slack.is_some() {
509            names.push("slack");
510        }
511        if self.providers.webhook.is_some() {
512            names.push("webhook");
513        }
514        if self.providers.pagerduty.is_some() {
515            names.push("pagerduty");
516        }
517        if self.providers.teams.is_some() {
518            names.push("teams");
519        }
520        if self.providers.opsgenie.is_some() {
521            names.push("opsgenie");
522        }
523        if self.providers.email.is_some() {
524            names.push("email");
525        }
526        names
527    }
528
529    fn check_routes(&self, origins: &[(Option<String>, usize)]) -> Result<(), SignalError> {
530        let known = self.provider_names();
531        for (route, (file, i)) in self.routes.iter().zip(origins) {
532            let at = match file {
533                Some(file) => format!("{file}: routes[{i}]"),
534                None => format!("routes[{i}]"),
535            };
536            if route.providers.is_empty() {
537                return Err(config_err(format!("{at} lists no providers")));
538            }
539            if route.dedup.is_some_and(|w| w.0.is_zero()) {
540                return Err(config_err(format!("{at}: dedup must be longer than 0s")));
541            }
542            if let Some(limit) = route.rate_limit {
543                if limit.max == 0 || limit.per.0.is_zero() {
544                    return Err(config_err(format!(
545                        "{at}: rate_limit needs max and per above 0"
546                    )));
547                }
548            }
549            for name in &route.providers {
550                if !known.contains(&name.as_str()) {
551                    return Err(config_err(format!(
552                        "{at} references provider '{name}', which is not configured under providers (configured: {})",
553                        known.join(", ")
554                    )));
555                }
556            }
557        }
558        Ok(())
559    }
560}
561
562fn config_err(msg: impl Into<String>) -> SignalError {
563    SignalError::Config(msg.into())
564}
565
566fn interpolate(
567    value: &mut serde_yaml::Value,
568    secrets: &HashMap<String, String>,
569) -> Result<(), SignalError> {
570    match value {
571        serde_yaml::Value::String(s) => {
572            *s = interpolate_str(s, secrets)?;
573        }
574        serde_yaml::Value::Sequence(items) => {
575            for item in items {
576                interpolate(item, secrets)?;
577            }
578        }
579        serde_yaml::Value::Mapping(map) => {
580            for (_, v) in map.iter_mut() {
581                interpolate(v, secrets)?;
582            }
583        }
584        serde_yaml::Value::Tagged(tagged) => interpolate(&mut tagged.value, secrets)?,
585        _ => {}
586    }
587    Ok(())
588}
589
590/// The `${...}` names in `input`, in order.
591fn placeholders(input: &str) -> Result<Vec<&str>, SignalError> {
592    let mut names = Vec::new();
593    let mut rest = input;
594    while let Some(start) = rest.find("${") {
595        let after = &rest[start + 2..];
596        let end = after
597            .find('}')
598            .ok_or_else(|| config_err(format!("unterminated ${{...}} in '{input}'")))?;
599        let name = &after[..end];
600        if name.is_empty() {
601            return Err(config_err(format!("empty ${{}} placeholder in '{input}'")));
602        }
603        names.push(name);
604        rest = &after[end + 1..];
605    }
606    Ok(names)
607}
608
609fn interpolate_str(input: &str, secrets: &HashMap<String, String>) -> Result<String, SignalError> {
610    let mut out = String::with_capacity(input.len());
611    let mut rest = input;
612    for name in placeholders(input)? {
613        let placeholder = format!("${{{name}}}");
614        let start = rest.find(&placeholder).expect("found by placeholders");
615        out.push_str(&rest[..start]);
616        let val = if crate::secrets::is_ref(name) {
617            secrets.get(name).cloned().ok_or_else(|| {
618                config_err(format!(
619                    "secret ${{{name}}} can only be read when the config is loaded by a client"
620                ))
621            })?
622        } else {
623            std::env::var(name).map_err(|_| {
624                config_err(format!(
625                    "environment variable {name} is referenced in signal.yaml but not set"
626                ))
627            })?
628        };
629        out.push_str(&val);
630        rest = &rest[start + placeholder.len()..];
631    }
632    out.push_str(rest);
633    Ok(out)
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639    use crate::signal::Severity;
640    use std::time::Duration;
641
642    const EXAMPLE: &str = include_str!("../../../python/examples/signal.example.yaml");
643
644    #[test]
645    fn example_file_parses_with_env_set() {
646        std::env::set_var("SLACK_WEBHOOK_URL", "https://hooks.example.test/abc");
647        let config = Config::from_yaml_str(EXAMPLE).expect("example config parses");
648        assert_eq!(
649            config
650                .providers
651                .slack
652                .as_ref()
653                .map(|s| s.webhook_url.as_str()),
654            Some("https://hooks.example.test/abc")
655        );
656        assert_eq!(config.routes.len(), 2);
657        assert_eq!(
658            config.routes[0].match_rule.severity,
659            vec![Severity::Error, Severity::Critical]
660        );
661        assert_eq!(config.storage.sink, SinkKind::Sqlite);
662        assert_eq!(config.storage.path, "./signals.db");
663    }
664
665    #[test]
666    fn empty_file_is_zero_config_default() {
667        let config = Config::from_yaml_str("").expect("empty config parses");
668        assert!(config.routes.is_empty());
669        assert_eq!(config.provider_names(), vec!["console"]);
670        assert_eq!(config.storage.path, DEFAULT_SQLITE_PATH);
671    }
672
673    #[test]
674    fn interpolates_multiple_placeholders_in_one_value() {
675        std::env::set_var("OPSSIGNAL_TEST_HOST", "example.test");
676        std::env::set_var("OPSSIGNAL_TEST_PORT", "8443");
677        let out = interpolate_str(
678            "https://${OPSSIGNAL_TEST_HOST}:${OPSSIGNAL_TEST_PORT}/x",
679            &HashMap::new(),
680        )
681        .expect("interpolates");
682        assert_eq!(out, "https://example.test:8443/x");
683    }
684
685    #[test]
686    fn missing_env_var_is_a_config_error_naming_the_variable() {
687        let yaml = "providers:\n  webhook:\n    url: \"${OPSSIGNAL_TEST_DEFINITELY_UNSET}\"\n";
688        let err = Config::from_yaml_str(yaml).expect_err("unset var must fail");
689        assert!(matches!(err, SignalError::Config(_)));
690        assert!(err.to_string().contains("OPSSIGNAL_TEST_DEFINITELY_UNSET"));
691    }
692
693    #[test]
694    fn route_to_unconfigured_provider_is_rejected() {
695        let yaml = "routes:\n  - match:\n      severity: [error]\n    providers: [slack]\n";
696        let err = Config::from_yaml_str(yaml).expect_err("slack is not configured");
697        assert!(err.to_string().contains("'slack'"));
698    }
699
700    #[test]
701    fn unknown_provider_key_is_rejected() {
702        let yaml = "providers:\n  slak:\n    webhook_url: x\n";
703        assert!(Config::from_yaml_str(yaml).is_err());
704    }
705
706    #[test]
707    fn unterminated_placeholder_is_rejected() {
708        assert!(interpolate_str("${NOPE", &HashMap::new()).is_err());
709    }
710
711    #[test]
712    fn route_dedup_and_rate_limit_parse() {
713        let yaml = "providers:\n  webhook:\n    url: https://example.test\nroutes:\n  - match: {}\n    providers: [webhook]\n    dedup: 10m\n    rate_limit:\n      max: 20\n      per: 1h\n  - match: {}\n    providers: [webhook]\n    dedup: 90\n";
714        let config = Config::from_yaml_str(yaml).expect("config");
715        let route = &config.routes[0];
716        assert_eq!(route.dedup.expect("dedup").0, Duration::from_secs(600));
717        let limit = route.rate_limit.expect("rate_limit");
718        assert_eq!((limit.max, limit.per.0), (20, Duration::from_secs(3600)));
719        assert_eq!(
720            config.routes[1].dedup.expect("dedup").0,
721            Duration::from_secs(90)
722        );
723    }
724
725    #[test]
726    fn bad_dedup_or_rate_limit_is_rejected() {
727        let base = "providers:\n  webhook:\n    url: https://example.test\nroutes:\n  - match: {}\n    providers: [webhook]\n";
728        for (extra, expect) in [
729            ("    dedup: 10 minutes\n", "invalid duration unit"),
730            ("    dedup: 0s\n", "dedup must be longer than 0s"),
731            (
732                "    rate_limit: {max: 0, per: 1m}\n",
733                "rate_limit needs max and per above 0",
734            ),
735            ("    rate_limit: {max: 5}\n", "missing field `per`"),
736            (
737                "    rate_limit: {max: 5, per: 1m, burst: 2}\n",
738                "unknown field `burst`",
739            ),
740        ] {
741            let err = Config::from_yaml_str(&format!("{base}{extra}")).expect_err(extra);
742            assert!(err.to_string().contains(expect), "{extra}: {err}");
743        }
744    }
745
746    #[test]
747    fn typo_in_a_route_match_is_rejected() {
748        let yaml = "providers:\n  webhook:\n    url: https://example.test\nroutes:\n  - match:\n      enviroment: prod\n    providers: [webhook]\n";
749        let err = Config::from_yaml_str(yaml).expect_err("typo");
750        assert!(
751            err.to_string().contains("unknown field `enviroment`"),
752            "{err}"
753        );
754    }
755
756    #[test]
757    fn route_with_no_providers_is_rejected() {
758        let yaml = "routes:\n  - match:\n      severity: [error]\n    providers: []\n";
759        let err = Config::from_yaml_str(yaml).expect_err("empty provider list");
760        assert!(err.to_string().contains("routes[0] lists no providers"));
761    }
762
763    #[test]
764    fn unknown_top_level_key_is_rejected() {
765        assert!(Config::from_yaml_str("providerz: {}\n").is_err());
766    }
767
768    #[test]
769    fn unknown_sink_kind_is_rejected() {
770        assert!(Config::from_yaml_str("storage:\n  sink: postgres\n").is_err());
771    }
772
773    #[test]
774    fn invalid_yaml_is_a_config_error() {
775        let err = Config::from_yaml_str("routes: [unclosed\n").expect_err("bad YAML");
776        assert!(matches!(err, SignalError::Config(_)));
777        assert!(err.to_string().contains("invalid YAML"));
778    }
779
780    #[test]
781    fn secret_placeholders_are_collected_from_values_only() {
782        let yaml = "# old: ${aws-sm:retired}\nproviders:\n  slack:\n    webhook_url: \"${aws-sm:prod/slack#url}\"\n  webhook:\n    url: \"https://${OPSSIGNAL_TEST_HOST}/${gcp-sm:ops/path}?k=${aws-sm:prod/slack#url}\"\n";
783        assert_eq!(
784            Layer::secret_refs(yaml).expect("refs"),
785            ["aws-sm:prod/slack#url", "gcp-sm:ops/path"]
786        );
787    }
788
789    #[test]
790    fn secret_placeholders_take_resolved_values() {
791        std::env::set_var("OPSSIGNAL_TEST_SECRET_HOST", "hooks.example.test");
792        let yaml = "providers:\n  webhook:\n    url: \"https://${OPSSIGNAL_TEST_SECRET_HOST}/${azure-kv:v/path}\"\n";
793        let err = Layer::from_yaml_str(yaml).expect_err("unresolved secret");
794        assert!(err.to_string().contains("${azure-kv:v/path}"), "{err}");
795
796        let secrets = HashMap::from([("azure-kv:v/path".to_string(), "abc".to_string())]);
797        let layer = Layer::from_yaml_str_with(yaml, &secrets).expect("layer");
798        assert_eq!(
799            layer.providers.webhook.expect("webhook").url,
800            "https://hooks.example.test/abc"
801        );
802    }
803
804    #[test]
805    fn empty_placeholder_is_rejected() {
806        assert!(interpolate_str("x${}y", &HashMap::new()).is_err());
807    }
808
809    #[test]
810    fn text_without_placeholders_is_unchanged() {
811        assert_eq!(
812            interpolate_str("https://example.test/$5{x", &HashMap::new()).expect("no placeholder"),
813            "https://example.test/$5{x"
814        );
815    }
816
817    #[test]
818    fn placeholders_inside_lists_are_interpolated() {
819        std::env::set_var("OPSSIGNAL_TEST_ENV_NAME", "prod");
820        let yaml = "providers:\n  webhook:\n    url: https://example.test\nroutes:\n  - match:\n      environment: \"${OPSSIGNAL_TEST_ENV_NAME}\"\n    providers: [webhook]\n";
821        let config = Config::from_yaml_str(yaml).expect("config");
822        assert_eq!(
823            config.routes[0].match_rule.environment.as_deref(),
824            Some("prod")
825        );
826    }
827
828    #[test]
829    fn provider_names_follow_configured_providers() {
830        let yaml = "providers:\n  webhook:\n    url: https://example.test\n";
831        let config = Config::from_yaml_str(yaml).expect("config");
832        assert_eq!(config.provider_names(), vec!["console", "webhook"]);
833    }
834
835    #[test]
836    fn pagerduty_and_teams_parse_and_can_be_routed_to() {
837        let yaml = "providers:\n  pagerduty:\n    routing_key: R0UT1NG\n  teams:\n    webhook_url: https://example.test/teams\nroutes:\n  - match:\n      severity: [critical]\n    providers: [pagerduty, teams]\n";
838        let config = Config::from_yaml_str(yaml).expect("config");
839        let pd = config.providers.pagerduty.as_ref().expect("pagerduty");
840        assert_eq!(pd.routing_key, "R0UT1NG");
841        assert_eq!(pd.url, None);
842        assert_eq!(
843            config.providers.teams.as_ref().expect("teams").webhook_url,
844            "https://example.test/teams"
845        );
846        assert_eq!(
847            config.provider_names(),
848            vec!["console", "pagerduty", "teams"]
849        );
850    }
851
852    #[test]
853    fn pagerduty_accepts_an_eu_endpoint() {
854        let yaml = "providers:\n  pagerduty:\n    routing_key: k\n    url: https://events.eu.pagerduty.com/v2/enqueue\n";
855        let config = Config::from_yaml_str(yaml).expect("config");
856        assert_eq!(
857            config
858                .providers
859                .pagerduty
860                .expect("pagerduty")
861                .url
862                .as_deref(),
863            Some("https://events.eu.pagerduty.com/v2/enqueue")
864        );
865    }
866
867    #[test]
868    fn pagerduty_without_a_routing_key_is_rejected() {
869        let yaml = "providers:\n  pagerduty:\n    url: https://example.test\n";
870        let err = Config::from_yaml_str(yaml).expect_err("routing_key is required");
871        assert!(err.to_string().contains("routing_key"), "{err}");
872    }
873
874    #[test]
875    fn unknown_teams_field_is_rejected() {
876        let yaml = "providers:\n  teams:\n    url: https://example.test\n";
877        let err = Config::from_yaml_str(yaml).expect_err("teams takes webhook_url");
878        assert!(err.to_string().contains("url"), "{err}");
879    }
880
881    #[test]
882    fn later_layer_replaces_pagerduty_and_teams() {
883        let base = Layer::from_yaml_str(
884            "providers:\n  pagerduty:\n    routing_key: org\n  teams:\n    webhook_url: https://example.test/org\n",
885        )
886        .expect("base");
887        let team = Layer::from_yaml_str("providers:\n  pagerduty:\n    routing_key: payments\n")
888            .expect("team");
889        let config = Config::from_layers([(None, base), (None, team)]).expect("merged");
890        assert_eq!(
891            config.providers.pagerduty.expect("pagerduty").routing_key,
892            "payments"
893        );
894        assert_eq!(
895            config.providers.teams.expect("teams").webhook_url,
896            "https://example.test/org"
897        );
898    }
899
900    #[test]
901    fn opsgenie_parses_with_tags_and_an_eu_endpoint() {
902        let yaml = "providers:\n  opsgenie:\n    api_key: K3Y\n    url: https://api.eu.opsgenie.com/v2/alerts\n    tags: [data, etl]\nroutes:\n  - match:\n      severity: [critical]\n    providers: [opsgenie]\n";
903        let config = Config::from_yaml_str(yaml).expect("config");
904        let og = config.providers.opsgenie.as_ref().expect("opsgenie");
905        assert_eq!(og.api_key, "K3Y");
906        assert_eq!(
907            og.url.as_deref(),
908            Some("https://api.eu.opsgenie.com/v2/alerts")
909        );
910        assert_eq!(og.tags, vec!["data", "etl"]);
911        assert_eq!(config.provider_names(), vec!["console", "opsgenie"]);
912    }
913
914    #[test]
915    fn opsgenie_without_an_api_key_is_rejected() {
916        let yaml = "providers:\n  opsgenie:\n    tags: [data]\n";
917        let err = Config::from_yaml_str(yaml).expect_err("api_key is required");
918        assert!(err.to_string().contains("api_key"), "{err}");
919    }
920
921    #[test]
922    fn email_parses_with_smtp_defaults() {
923        let yaml = "providers:\n  email:\n    from: \"OpsSignal <alerts@example.test>\"\n    to: [oncall@example.test]\n    smtp:\n      host: smtp.example.test\n      username: u\n      password: p\nroutes:\n  - match:\n      severity: [error]\n    providers: [email]\n";
924        let config = Config::from_yaml_str(yaml).expect("config");
925        let email = config.providers.email.as_ref().expect("email");
926        assert_eq!(email.to, vec!["oncall@example.test"]);
927        assert!(email.cc.is_empty());
928        let smtp = email.smtp.as_ref().expect("smtp");
929        assert_eq!(smtp.tls, SmtpTls::Starttls);
930        assert_eq!(smtp.port(), 587);
931        assert_eq!(config.provider_names(), vec!["console", "email"]);
932    }
933
934    #[test]
935    fn email_tls_modes_parse() {
936        let yaml = "providers:\n  email:\n    from: a@example.test\n    to: [b@example.test]\n    smtp:\n      host: relay.internal\n      tls: none\n";
937        let config = Config::from_yaml_str(yaml).expect("config");
938        let smtp = config.providers.email.expect("email").smtp.expect("smtp");
939        assert_eq!(smtp.tls, SmtpTls::None);
940        assert_eq!(smtp.port(), 25);
941    }
942
943    #[test]
944    fn email_with_a_bad_address_is_rejected_at_load() {
945        let yaml = "providers:\n  email:\n    from: a@example.test\n    to: [not-an-address]\n    smtp:\n      host: h\n";
946        let err = Config::from_yaml_str(yaml).expect_err("bad address");
947        assert!(err.to_string().contains("not-an-address"), "{err}");
948    }
949
950    #[test]
951    fn email_without_recipients_is_rejected() {
952        let yaml = "providers:\n  email:\n    from: a@example.test\n    to: []\n    smtp:\n      host: h\n";
953        let err = Config::from_yaml_str(yaml).expect_err("no recipients");
954        assert!(err.to_string().contains("recipients"), "{err}");
955    }
956
957    #[test]
958    fn email_username_without_password_is_rejected() {
959        let yaml = "providers:\n  email:\n    from: a@example.test\n    to: [b@example.test]\n    smtp:\n      host: h\n      username: u\n";
960        let err = Config::from_yaml_str(yaml).expect_err("half credentials");
961        assert!(err.to_string().contains("password"), "{err}");
962    }
963
964    #[test]
965    fn email_needs_exactly_one_transport() {
966        let neither = "providers:\n  email:\n    from: a@example.test\n    to: [b@example.test]\n";
967        let err = Config::from_yaml_str(neither).expect_err("no transport");
968        assert!(err.to_string().contains("exactly one"), "{err}");
969        let both = "providers:\n  email:\n    from: a@example.test\n    to: [b@example.test]\n    smtp:\n      host: h\n    ses: {}\n";
970        let err = Config::from_yaml_str(both).expect_err("two transports");
971        assert!(err.to_string().contains("exactly one"), "{err}");
972    }
973
974    #[cfg(feature = "aws")]
975    #[test]
976    fn email_over_ses_parses_in_an_aws_build() {
977        let yaml = "providers:\n  email:\n    from: a@example.test\n    to: [b@example.test]\n    ses:\n      region: eu-west-1\n      configuration_set: alerts\n";
978        let config = Config::from_yaml_str(yaml).expect("config");
979        let ses = config.providers.email.expect("email").ses.expect("ses");
980        assert_eq!(ses.region.as_deref(), Some("eu-west-1"));
981        assert_eq!(ses.configuration_set.as_deref(), Some("alerts"));
982    }
983
984    #[test]
985    fn from_file_reports_the_path_on_error() {
986        let missing = std::env::temp_dir().join("opssignal-definitely-missing.yaml");
987        let err = Config::from_file(&missing).expect_err("missing file");
988        assert!(err
989            .to_string()
990            .contains("opssignal-definitely-missing.yaml"));
991
992        let bad = std::env::temp_dir().join(format!("opssignal-bad-{}.yaml", std::process::id()));
993        std::fs::write(&bad, "providers:\n  slak: {}\n").expect("write");
994        let err = Config::from_file(&bad).expect_err("bad file");
995        assert!(err.to_string().contains(&*bad.to_string_lossy()));
996        let _ = std::fs::remove_file(&bad);
997    }
998
999    #[test]
1000    fn discover_honours_signal_config_and_rejects_a_missing_file() {
1001        // The only test that touches $SIGNAL_CONFIG, so it cannot race
1002        // another test in this binary.
1003        let path =
1004            std::env::temp_dir().join(format!("opssignal-discover-{}.yaml", std::process::id()));
1005        std::fs::write(&path, "storage:\n  path: /tmp/discovered.db\n").expect("write");
1006
1007        std::env::set_var(CONFIG_ENV_VAR, &path);
1008        let found = Config::discover();
1009        std::env::set_var(CONFIG_ENV_VAR, path.with_extension("missing"));
1010        let missing = Config::discover();
1011        std::env::remove_var(CONFIG_ENV_VAR);
1012        let _ = std::fs::remove_file(&path);
1013
1014        assert_eq!(
1015            found.expect("discovered").storage.path,
1016            "/tmp/discovered.db"
1017        );
1018        let err = missing.expect_err("missing $SIGNAL_CONFIG target");
1019        assert!(err.to_string().contains("not a file"));
1020    }
1021
1022    #[test]
1023    fn provider_templates_load_from_yaml() {
1024        std::env::set_var("OPSSIGNAL_TEST_SLACK_TPL", "https://hooks.example.test/t");
1025        let yaml = r#"
1026providers:
1027  slack:
1028    webhook_url: "${OPSSIGNAL_TEST_SLACK_TPL}"
1029    template:
1030      text: |
1031        {{ emoji }} *{{ title }}* in {{ environment | default("unknown") }}
1032  pagerduty:
1033    routing_key: k
1034    template:
1035      summary: "[{{ severity|upper }}] {{ title }}"
1036"#;
1037        let config = Config::from_yaml_str(yaml).expect("templates parse");
1038        let slack = config.providers.slack.expect("slack");
1039        assert_eq!(slack.webhook_url, "https://hooks.example.test/t");
1040        assert!(slack.template["text"].starts_with("{{ emoji }} *{{ title }}*"));
1041        assert_eq!(
1042            config.providers.pagerduty.expect("pd").template["summary"],
1043            "[{{ severity|upper }}] {{ title }}"
1044        );
1045    }
1046
1047    #[test]
1048    fn template_mistakes_stop_the_load() {
1049        for (yaml, expect) in [
1050            (
1051                "providers:\n  teams:\n    webhook_url: u\n    template:\n      body: x\n",
1052                "providers.teams.template.body",
1053            ),
1054            (
1055                "providers:\n  console:\n    template:\n      text: \"{{ titel }}\"\n",
1056                "unknown variable titel",
1057            ),
1058            (
1059                "providers:\n  webhook:\n    url: u\n    template:\n      body: \"{% if %}\"\n",
1060                "providers.webhook.template.body",
1061            ),
1062        ] {
1063            let err = Config::from_yaml_str(yaml).expect_err(expect);
1064            assert!(err.to_string().contains(expect), "{err}");
1065        }
1066    }
1067
1068    #[test]
1069    fn a_team_layer_can_restate_a_provider_with_its_own_template() {
1070        let base = Layer::from_yaml_str(
1071            "providers:\n  slack:\n    webhook_url: https://hooks.example.test/shared\n",
1072        )
1073        .expect("base");
1074        let team = Layer::from_yaml_str(
1075            "providers:\n  slack:\n    webhook_url: https://hooks.example.test/data\n    template:\n      text: \"[data] {{ title }}\"\n",
1076        )
1077        .expect("team");
1078        let config =
1079            Config::from_layers([(Some("base".into()), base), (Some("team".into()), team)])
1080                .expect("merged");
1081        let slack = config.providers.slack.expect("slack");
1082        assert_eq!(slack.webhook_url, "https://hooks.example.test/data");
1083        assert_eq!(slack.template["text"], "[data] {{ title }}");
1084    }
1085}