1use async_trait::async_trait;
4use cronoq::{ScaleError, Scaler};
5
6pub struct WebhookScaler {
9 url: String,
10 token: Option<String>,
11 client: reqwest::Client,
12}
13
14impl std::fmt::Debug for WebhookScaler {
15 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 f.debug_struct("WebhookScaler")
17 .field("url", &self.url)
18 .field("token", &self.token.as_ref().map(|_| "<redacted>"))
19 .finish()
20 }
21}
22
23impl WebhookScaler {
24 pub fn new(url: String, token: Option<String>) -> anyhow::Result<Self> {
26 let parsed = reqwest::Url::parse(&url)
27 .map_err(|e| anyhow::anyhow!("scaler webhook url '{url}': {e}"))?;
28 match parsed.scheme() {
29 "https" => {}
30 "http" => {
31 let local = matches!(parsed.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"));
32 if !local {
33 tracing::warn!(url = %url, "scaler webhook uses plain http; use https outside a trusted network");
34 if token.is_some() {
35 tracing::warn!("the scaler webhook token is sent over plain http");
36 }
37 }
38 }
39 s => anyhow::bail!("scaler webhook url must be http or https, not {s}"),
40 }
41 let _ = rustls::crypto::ring::default_provider().install_default();
44 let client = reqwest::Client::builder()
45 .timeout(std::time::Duration::from_secs(10))
46 .redirect(reqwest::redirect::Policy::none())
47 .build()?;
48 Ok(WebhookScaler { url, token, client })
49 }
50}
51
52#[async_trait]
53impl Scaler for WebhookScaler {
54 async fn scale_to(&self, pool: &str, desired: u32) -> Result<u32, ScaleError> {
55 let mut req = self
56 .client
57 .post(&self.url)
58 .json(&serde_json::json!({ "pool": pool, "desired": desired }));
59 if let Some(t) = &self.token {
60 req = req.bearer_auth(t);
61 }
62 let resp = req
63 .send()
64 .await
65 .map_err(|e| ScaleError::Other(e.without_url().to_string()))?;
66 if !resp.status().is_success() {
67 return Err(ScaleError::Other(format!(
68 "scaler webhook returned {}",
69 resp.status()
70 )));
71 }
72 Err(ScaleError::External)
74 }
75}