opssignal_core/providers/
external.rs1use crate::signal::{Signal, SignalError};
12use std::collections::HashMap;
13use std::sync::{Arc, OnceLock, RwLock};
14use std::time::Duration;
15
16const SEND_TIMEOUT: Duration = Duration::from_secs(60);
19
20pub trait ExternalSender: Send + Sync {
24 fn send(&self, provider: &str, config_json: &str, signal_json: &str) -> Result<(), String>;
25}
26
27type SenderRegistry = RwLock<HashMap<String, Arc<dyn ExternalSender>>>;
28
29fn senders() -> &'static SenderRegistry {
30 static SENDERS: OnceLock<SenderRegistry> = OnceLock::new();
31 SENDERS.get_or_init(Default::default)
32}
33
34pub fn register_cloud_sender(cloud: &str, sender: Arc<dyn ExternalSender>) {
37 senders()
38 .write()
39 .unwrap_or_else(|e| e.into_inner())
40 .insert(cloud.to_string(), sender);
41}
42
43fn registered_sender(cloud: &str) -> Option<Arc<dyn ExternalSender>> {
44 senders()
45 .read()
46 .unwrap_or_else(|e| e.into_inner())
47 .get(cloud)
48 .cloned()
49}
50
51pub fn cloud_supported(cloud: &str) -> bool {
54 let built_in = (cloud == "aws" && cfg!(feature = "aws"))
55 || (cloud == "gcp" && cfg!(feature = "gcp"))
56 || (cloud == "azure" && cfg!(feature = "azure"));
57 built_in || registered_sender(cloud).is_some()
58}
59
60pub(crate) fn missing_support(what: &str, cloud: &str) -> String {
62 format!(
63 "{what} needs the {cloud} add-on: pip install 'opssignal[{cloud}]' for Python, \
64 or a CLI or C library built with `--features {cloud}`"
65 )
66}
67
68#[cfg_attr(feature = "aws", allow(dead_code))]
70pub(crate) async fn send_external(
72 cloud: &str,
73 provider: &str,
74 config_json: String,
75 signal: &Signal,
76) -> Result<(), SignalError> {
77 let sender = registered_sender(cloud)
78 .ok_or_else(|| SignalError::Delivery(missing_support(provider, cloud)))?;
79 let signal_json =
80 serde_json::to_string(signal).map_err(|e| SignalError::Delivery(e.to_string()))?;
81 let provider = provider.to_string();
82 let (tx, rx) = tokio::sync::oneshot::channel();
83 std::thread::Builder::new()
86 .name("opssignal-send".into())
87 .spawn(move || {
88 let _ = tx.send(sender.send(&provider, &config_json, &signal_json));
89 })
90 .map_err(|e| SignalError::Delivery(format!("cannot start send thread: {e}")))?;
91 match tokio::time::timeout(SEND_TIMEOUT, rx).await {
92 Ok(Ok(result)) => result.map_err(SignalError::Delivery),
93 Ok(Err(_)) => Err(SignalError::Delivery(
94 "the add-on sender failed without an answer".into(),
95 )),
96 Err(_) => Err(SignalError::Delivery(format!(
97 "timed out after {SEND_TIMEOUT:?}"
98 ))),
99 }
100}
101
102pub fn send_blocking(provider: &str, config_json: &str, signal_json: &str) -> Result<(), String> {
106 use crate::delivery::Provider;
107
108 let signal: Signal =
109 serde_json::from_str(signal_json).map_err(|e| format!("invalid signal: {e}"))?;
110 let provider: Box<dyn Provider> = match provider {
111 "email" => {
112 let config = serde_json::from_str(config_json)
113 .map_err(|e| format!("invalid email config: {e}"))?;
114 Box::new(super::email::EmailProvider::new(config))
115 }
116 other => return Err(format!("unknown provider {other:?}")),
117 };
118 let runtime = tokio::runtime::Builder::new_current_thread()
119 .enable_all()
120 .build()
121 .map_err(|e| format!("failed to start runtime: {e}"))?;
122 runtime
123 .block_on(provider.send(&signal))
124 .map_err(|e| match e {
125 SignalError::Delivery(msg) => msg,
126 other => other.to_string(),
127 })
128}