1use 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#[derive(Debug, Clone, Default, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct Layer {
45 #[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 pub fn from_yaml_str(text: &str) -> Result<Self, SignalError> {
79 Self::from_yaml_str_with(text, &HashMap::new())
80 }
81
82 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 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 Ok(if value.is_null() {
131 serde_yaml::Value::Mapping(Default::default())
132 } else {
133 value
134 })
135}
136
137#[derive(Debug, Clone, Default, Deserialize)]
140#[serde(deny_unknown_fields)]
141pub struct ProvidersConfig {
142 #[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 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 #[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 #[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 #[serde(default)]
208 pub template: TemplateConfig,
209}
210
211#[derive(Debug, Clone, Deserialize)]
212#[serde(deny_unknown_fields)]
213pub struct PagerDutyConfig {
214 pub routing_key: String,
216 #[serde(default)]
219 pub url: Option<String>,
220 #[serde(default)]
222 pub template: TemplateConfig,
223}
224
225#[derive(Debug, Clone, Deserialize)]
226#[serde(deny_unknown_fields)]
227pub struct TeamsConfig {
228 pub webhook_url: String,
230 #[serde(default)]
232 pub template: TemplateConfig,
233}
234
235#[derive(Debug, Clone, Deserialize)]
236#[serde(deny_unknown_fields)]
237pub struct OpsgenieConfig {
238 pub api_key: String,
240 #[serde(default)]
243 pub url: Option<String>,
244 #[serde(default)]
247 pub tags: Vec<String>,
248 #[serde(default)]
250 pub template: TemplateConfig,
251}
252
253#[derive(Debug, Clone, Deserialize, Serialize)]
256#[serde(deny_unknown_fields)]
257pub struct EmailConfig {
258 pub from: String,
260 pub to: Vec<String>,
262 #[serde(default)]
263 pub cc: Vec<String>,
264 #[serde(default)]
266 pub subject_prefix: Option<String>,
267 #[serde(default)]
269 pub smtp: Option<SmtpConfig>,
270 #[serde(default)]
273 pub ses: Option<SesConfig>,
274 #[serde(default, skip_serializing_if = "TemplateConfig::is_empty")]
276 pub template: TemplateConfig,
277}
278
279pub enum EmailTransport<'a> {
281 Smtp(&'a SmtpConfig),
282 Ses(&'a SesConfig),
283}
284
285impl EmailConfig {
286 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 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 #[serde(default)]
340 pub region: Option<String>,
341 #[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 #[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
373#[serde(rename_all = "lowercase")]
374pub enum SmtpTls {
375 #[default]
377 Starttls,
378 Tls,
380 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 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 pub fn from_layers(
441 layers: impl IntoIterator<Item = (Option<String>, Layer)>,
442 ) -> Result<Self, SignalError> {
443 let mut config = Config::default();
444 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 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 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 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
590fn 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 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}