1use 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
17pub struct UpdateCheckHandler {
51 current_version: &'static str,
52 notify_tx: mpsc::Sender<String>,
53 http_client: reqwest::Client,
54 base_url: String,
56}
57
58#[derive(Deserialize)]
59struct ReleaseInfo {
60 tag_name: Option<String>,
61}
62
63impl UpdateCheckHandler {
64 #[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 #[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 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 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 #[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 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}