Skip to main content

zeph_scheduler/
update_check.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::future::Future;
5use std::pin::Pin;
6
7use semver::Version;
8use serde::Deserialize;
9use tokio::sync::mpsc;
10
11use crate::error::SchedulerError;
12use crate::task::TaskHandler;
13
14const GITHUB_RELEASES_URL: &str = "https://api.github.com/repos/bug-ops/zeph/releases/latest";
15const MAX_RESPONSE_BYTES: usize = 64 * 1024;
16
17/// [`TaskHandler`] that polls the GitHub releases API for a newer Zeph version.
18///
19/// On each execution, `UpdateCheckHandler` fetches the latest release from
20/// `https://api.github.com/repos/bug-ops/zeph/releases/latest`, compares the
21/// `tag_name` field against `current_version` using semantic versioning, and sends a
22/// human-readable notification message on `notify_tx` when a newer release is found.
23///
24/// Network and parse errors are logged as warnings; `execute` always returns `Ok(())`
25/// so a transient failure does not stop the scheduler.
26///
27/// # Examples
28///
29/// ```rust,no_run
30/// use tokio::sync::mpsc;
31/// use zeph_scheduler::UpdateCheckHandler;
32///
33/// # #[tokio::main]
34/// # async fn main() {
35/// let (tx, mut rx) = mpsc::channel(1);
36/// let handler = UpdateCheckHandler::new(env!("CARGO_PKG_VERSION"), tx);
37///
38/// use zeph_scheduler::TaskHandler;
39/// handler
40///     .execute(&serde_json::Value::Null)
41///     .await
42///     .expect("update check should not fail");
43///
44/// // A notification is sent only when a newer version exists on GitHub.
45/// if let Ok(msg) = rx.try_recv() {
46///     println!("{msg}");
47/// }
48/// # }
49/// ```
50pub struct UpdateCheckHandler {
51    current_version: &'static str,
52    notify_tx: mpsc::Sender<String>,
53    http_client: reqwest::Client,
54    /// Base URL for the GitHub releases API. Configurable for testing.
55    base_url: String,
56}
57
58#[derive(Deserialize)]
59struct ReleaseInfo {
60    tag_name: Option<String>,
61}
62
63impl UpdateCheckHandler {
64    /// Create a new handler.
65    ///
66    /// `current_version` should be `env!("CARGO_PKG_VERSION")`.
67    /// Notifications are sent as formatted strings via `notify_tx`.
68    ///
69    /// # Panics
70    ///
71    /// Panics if the underlying `reqwest` client cannot be constructed (unreachable in practice).
72    #[must_use]
73    pub fn new(current_version: &'static str, notify_tx: mpsc::Sender<String>) -> Self {
74        let http_client = reqwest::Client::builder()
75            .timeout(std::time::Duration::from_secs(10))
76            .user_agent(format!("zeph/{current_version}"))
77            .build()
78            .expect("reqwest client builder should not fail with timeout and user_agent");
79        Self {
80            current_version,
81            notify_tx,
82            http_client,
83            base_url: GITHUB_RELEASES_URL.to_owned(),
84        }
85    }
86
87    /// Override the GitHub releases API URL.
88    ///
89    /// Intended for tests only: point the handler at a local mock server so the
90    /// test does not make real network requests.
91    #[must_use]
92    pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
93        self.base_url = url.into();
94        self
95    }
96
97    /// Extract and compare versions; returns `Some(remote_version_str)` when remote > current.
98    fn newer_version(current: &str, tag_name: &str) -> Option<String> {
99        let remote_str = tag_name.trim_start_matches('v');
100        if remote_str.is_empty() {
101            return None;
102        }
103        let current_v = Version::parse(current).ok()?;
104        let remote_v = Version::parse(remote_str).ok()?;
105        if remote_v > current_v {
106            Some(remote_str.to_owned())
107        } else {
108            None
109        }
110    }
111}
112
113impl TaskHandler for UpdateCheckHandler {
114    /// Always `true`: every execution fetches the GitHub releases API over the network.
115    fn reads_external_content(&self) -> bool {
116        true
117    }
118
119    fn execute(
120        &self,
121        _config: &serde_json::Value,
122    ) -> Pin<Box<dyn Future<Output = Result<(), SchedulerError>> + Send + '_>> {
123        Box::pin(async move {
124            let resp = self
125                .http_client
126                .get(&self.base_url)
127                .header("Accept", "application/vnd.github+json")
128                .send()
129                .await;
130
131            let resp = match resp {
132                Ok(r) => r,
133                Err(e) => {
134                    tracing::warn!("update check request failed: {e}");
135                    return Ok(());
136                }
137            };
138
139            if !resp.status().is_success() {
140                tracing::warn!("update check: HTTP {}", resp.status());
141                return Ok(());
142            }
143
144            let bytes = match resp.bytes().await {
145                Ok(b) => b,
146                Err(e) => {
147                    tracing::warn!("update check: failed to read response body: {e}");
148                    return Ok(());
149                }
150            };
151            if bytes.len() > MAX_RESPONSE_BYTES {
152                tracing::warn!(
153                    "update check: response body too large ({} bytes), skipping",
154                    bytes.len()
155                );
156                return Ok(());
157            }
158            let info: ReleaseInfo = match serde_json::from_slice(&bytes) {
159                Ok(v) => v,
160                Err(e) => {
161                    tracing::warn!("update check response parse failed: {e}");
162                    return Ok(());
163                }
164            };
165
166            let Some(tag_name) = info.tag_name else {
167                tracing::warn!("update check: missing tag_name in response");
168                return Ok(());
169            };
170
171            match Self::newer_version(self.current_version, &tag_name) {
172                Some(remote) => {
173                    let msg = format!(
174                        "New version available: v{remote} (current: v{}).\nUpdate: https://github.com/bug-ops/zeph/releases/tag/v{remote}",
175                        self.current_version
176                    );
177                    tracing::debug!("update available: {remote}");
178                    let _ = self.notify_tx.send(msg).await;
179                }
180                None => {
181                    tracing::debug!(
182                        current = self.current_version,
183                        remote = tag_name,
184                        "no update available"
185                    );
186                }
187            }
188
189            Ok(())
190        })
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use wiremock::matchers::{method, path};
197    use wiremock::{Mock, MockServer, ResponseTemplate};
198
199    use super::*;
200
201    fn make_handler(
202        current_version: &'static str,
203        tx: mpsc::Sender<String>,
204        server_url: &str,
205    ) -> UpdateCheckHandler {
206        UpdateCheckHandler::new(current_version, tx).with_base_url(server_url)
207    }
208
209    #[test]
210    fn newer_version_detects_upgrade() {
211        assert_eq!(
212            UpdateCheckHandler::newer_version("0.11.0", "v0.12.0"),
213            Some("0.12.0".to_owned())
214        );
215    }
216
217    #[test]
218    fn newer_version_same_version_no_notify() {
219        assert_eq!(UpdateCheckHandler::newer_version("0.11.0", "v0.11.0"), None);
220    }
221
222    #[test]
223    fn newer_version_older_remote_no_notify() {
224        assert_eq!(UpdateCheckHandler::newer_version("0.11.0", "v0.10.0"), None);
225    }
226
227    #[test]
228    fn newer_version_strips_v_prefix() {
229        assert_eq!(
230            UpdateCheckHandler::newer_version("1.0.0", "v2.0.0"),
231            Some("2.0.0".to_owned())
232        );
233        assert_eq!(
234            UpdateCheckHandler::newer_version("1.0.0", "2.0.0"),
235            Some("2.0.0".to_owned())
236        );
237    }
238
239    #[test]
240    fn newer_version_invalid_current_returns_none() {
241        assert_eq!(
242            UpdateCheckHandler::newer_version("not-semver", "v1.0.0"),
243            None
244        );
245    }
246
247    #[test]
248    fn newer_version_invalid_remote_returns_none() {
249        assert_eq!(
250            UpdateCheckHandler::newer_version("1.0.0", "v-garbage"),
251            None
252        );
253    }
254
255    #[test]
256    fn newer_version_empty_tag_returns_none() {
257        assert_eq!(UpdateCheckHandler::newer_version("1.0.0", ""), None);
258    }
259
260    // Prerelease versions (e.g. 0.12.0-rc.1) compare as greater than 0.11.0 per semver spec.
261    // This is intentional: users should be notified of release candidates if they appear
262    // on the GitHub releases/latest endpoint (which typically only returns stable releases).
263    #[test]
264    fn newer_version_prerelease_is_notified() {
265        assert_eq!(
266            UpdateCheckHandler::newer_version("0.11.0", "v0.12.0-rc.1"),
267            Some("0.12.0-rc.1".to_owned())
268        );
269    }
270
271    #[tokio::test]
272    async fn test_execute_newer_version_sends_notification() {
273        let server = MockServer::start().await;
274        Mock::given(method("GET"))
275            .and(path("/"))
276            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
277                "tag_name": "v99.0.0"
278            })))
279            .mount(&server)
280            .await;
281
282        let (tx, mut rx) = mpsc::channel(1);
283        let handler = make_handler("0.11.0", tx, &server.uri());
284
285        handler
286            .execute(&serde_json::Value::Null)
287            .await
288            .expect("handler must not return an error");
289
290        let msg = rx.try_recv().expect("notification must be sent");
291        assert!(
292            msg.contains("99.0.0"),
293            "notification should mention new version"
294        );
295        assert!(
296            msg.contains("0.11.0"),
297            "notification should mention current version"
298        );
299    }
300
301    #[tokio::test]
302    async fn test_execute_same_version_no_notification() {
303        let server = MockServer::start().await;
304        Mock::given(method("GET"))
305            .and(path("/"))
306            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
307                "tag_name": "v0.11.0"
308            })))
309            .mount(&server)
310            .await;
311
312        let (tx, mut rx) = mpsc::channel(1);
313        let handler = make_handler("0.11.0", tx, &server.uri());
314
315        handler
316            .execute(&serde_json::Value::Null)
317            .await
318            .expect("handler must not return an error");
319
320        assert!(
321            rx.try_recv().is_err(),
322            "no notification expected for same version"
323        );
324    }
325
326    #[tokio::test]
327    async fn test_execute_http_404_no_notification_no_panic() {
328        let server = MockServer::start().await;
329        Mock::given(method("GET"))
330            .and(path("/"))
331            .respond_with(ResponseTemplate::new(404))
332            .mount(&server)
333            .await;
334
335        let (tx, mut rx) = mpsc::channel(1);
336        let handler = make_handler("0.11.0", tx, &server.uri());
337
338        let result = handler.execute(&serde_json::Value::Null).await;
339        assert!(result.is_ok(), "handler must return Ok on 404");
340        assert!(rx.try_recv().is_err(), "no notification expected on 404");
341    }
342
343    #[tokio::test]
344    async fn test_execute_http_429_rate_limit_graceful() {
345        let server = MockServer::start().await;
346        Mock::given(method("GET"))
347            .and(path("/"))
348            .respond_with(ResponseTemplate::new(429))
349            .mount(&server)
350            .await;
351
352        let (tx, mut rx) = mpsc::channel(1);
353        let handler = make_handler("0.11.0", tx, &server.uri());
354
355        let result = handler.execute(&serde_json::Value::Null).await;
356        assert!(result.is_ok(), "handler must return Ok on 429");
357        assert!(rx.try_recv().is_err(), "no notification expected on 429");
358    }
359
360    #[tokio::test]
361    async fn test_execute_http_500_server_error_graceful() {
362        let server = MockServer::start().await;
363        Mock::given(method("GET"))
364            .and(path("/"))
365            .respond_with(ResponseTemplate::new(500))
366            .mount(&server)
367            .await;
368
369        let (tx, mut rx) = mpsc::channel(1);
370        let handler = make_handler("0.11.0", tx, &server.uri());
371
372        let result = handler.execute(&serde_json::Value::Null).await;
373        assert!(result.is_ok(), "handler must return Ok on 500");
374        assert!(rx.try_recv().is_err(), "no notification expected on 500");
375    }
376
377    #[tokio::test]
378    async fn test_execute_malformed_json_graceful() {
379        let server = MockServer::start().await;
380        Mock::given(method("GET"))
381            .and(path("/"))
382            .respond_with(ResponseTemplate::new(200).set_body_string("this is not json {{{"))
383            .mount(&server)
384            .await;
385
386        let (tx, mut rx) = mpsc::channel(1);
387        let handler = make_handler("0.11.0", tx, &server.uri());
388
389        let result = handler.execute(&serde_json::Value::Null).await;
390        assert!(result.is_ok(), "handler must return Ok on malformed JSON");
391        assert!(
392            rx.try_recv().is_err(),
393            "no notification expected for malformed JSON"
394        );
395    }
396
397    #[tokio::test]
398    async fn test_execute_missing_tag_name_graceful() {
399        let server = MockServer::start().await;
400        Mock::given(method("GET"))
401            .and(path("/"))
402            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
403                "name": "Latest Release",
404                "published_at": "2024-01-01"
405            })))
406            .mount(&server)
407            .await;
408
409        let (tx, mut rx) = mpsc::channel(1);
410        let handler = make_handler("0.11.0", tx, &server.uri());
411
412        let result = handler.execute(&serde_json::Value::Null).await;
413        assert!(result.is_ok(), "handler must return Ok on missing tag_name");
414        assert!(
415            rx.try_recv().is_err(),
416            "no notification expected for missing tag_name"
417        );
418    }
419
420    #[tokio::test]
421    async fn test_execute_oversized_body_graceful() {
422        let server = MockServer::start().await;
423        // Body larger than MAX_RESPONSE_BYTES (64 KB): 65 537 bytes
424        let large_body = "x".repeat(MAX_RESPONSE_BYTES + 1);
425        Mock::given(method("GET"))
426            .and(path("/"))
427            .respond_with(ResponseTemplate::new(200).set_body_string(large_body))
428            .mount(&server)
429            .await;
430
431        let (tx, mut rx) = mpsc::channel(1);
432        let handler = make_handler("0.11.0", tx, &server.uri());
433
434        let result = handler.execute(&serde_json::Value::Null).await;
435        assert!(result.is_ok(), "handler must return Ok for oversized body");
436        assert!(
437            rx.try_recv().is_err(),
438            "no notification expected for oversized body"
439        );
440    }
441}