From d760d9dc2b2826ed251ebd9fff00203d47d6af65 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 00:24:12 +0000 Subject: [PATCH 1/2] Add HTTP-based cron monitoring support Report the lifecycle of each scheduled backup run to an HTTP-based cron monitoring service (such as Sentry Crons or healthchecks.io) via simple HTTP GET requests. A new top-level `monitor` config key accepts an optional URL for each of the `start`, `success`, and `failure` states. The relevant URL is fetched when a run starts and when it completes; a run is reported as a failure if any policy produced one or more errors and a success otherwise. Reporting is best-effort, so an unreachable monitor only logs a warning and never affects the backup itself. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Tzz3yuCYDQ2ghxW81e6FMS --- README.md | 27 ++++++ docs/.vuepress/config.ts | 3 +- docs/guide/monitors.md | 69 +++++++++++++++ examples/config.yaml | 9 ++ src/config.rs | 37 +++++++- src/main.rs | 46 +++++++--- src/monitor.rs | 176 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 354 insertions(+), 13 deletions(-) create mode 100644 docs/guide/monitors.md create mode 100644 src/monitor.rs diff --git a/README.md b/README.md index 7f248a6..1c21f0f 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,33 @@ OTEL_TRACES_SAMPLER=traceidratio OTEL_TRACES_SAMPLER_ARG=1.0 ``` +### Cron Monitoring + +If you run this tool on a schedule, you'll often want to be alerted when a backup +run fails to start or complete. To support this, GitHub Backup can report the +state of each scheduled run to an HTTP-based cron monitoring service such as +[Sentry Cron Monitors](https://docs.sentry.io/product/crons/) or +[healthchecks.io](https://healthchecks.io/). + +Monitoring is configured under the top-level `monitor` key, where you can provide +a separate URL for each state you care about. Each URL is fetched with a simple +HTTP `GET` request when the corresponding state is reached, and any state you +omit is simply not reported. + +```yaml +monitor: + # Fetched when a backup run starts. + start: https://sentry.io/api/0/organizations/your-org/monitors/github-backup/checkins/?status=in_progress + # Fetched when a backup run completes successfully. + success: https://sentry.io/api/0/organizations/your-org/monitors/github-backup/checkins/?status=ok + # Fetched when a backup run completes with one or more errors. + failure: https://sentry.io/api/0/organizations/your-org/monitors/github-backup/checkins/?status=error +``` + +A run is reported as a `failure` if any policy reports one or more errors, and as +a `success` otherwise. Reporting is best-effort: if the monitoring service can't +be reached, a warning is logged but the backup run itself is unaffected. + ## Filters This tool allows you to configure filters to control which GitHub repositories are backed up and diff --git a/docs/.vuepress/config.ts b/docs/.vuepress/config.ts index 84ef8ab..8dca884 100644 --- a/docs/.vuepress/config.ts +++ b/docs/.vuepress/config.ts @@ -79,7 +79,8 @@ export default defineUserConfig({ children: [ '/guide/README.md', '/guide/enterprise.md', - '/guide/telemetry.md' + '/guide/telemetry.md', + '/guide/monitors.md' ] }, { diff --git a/docs/guide/monitors.md b/docs/guide/monitors.md new file mode 100644 index 0000000..75553c0 --- /dev/null +++ b/docs/guide/monitors.md @@ -0,0 +1,69 @@ +# Cron Monitoring +GitHub Backup is designed to run unattended on a schedule, which makes it +important to know when a backup run fails to start or complete. To support this, +GitHub Backup can report the state of each scheduled run to an HTTP-based cron +monitoring service such as [Sentry Cron Monitors](https://docs.sentry.io/product/crons/) +or [healthchecks.io](https://healthchecks.io/). + +Whenever a backup run starts or completes, GitHub Backup will make a simple HTTP +`GET` request to the URL you've configured for that state, allowing your +monitoring service to track whether your backups are running as expected and to +alert you if they stop. + +## Configuration +Monitoring is configured under the top-level `monitor` key in your configuration +file. You may provide a separate URL for each of the `start`, `success`, and +`failure` states, and any state you leave out is simply not reported. + +```yaml +schedule: "0 * * * *" + +monitor: + # Fetched when a backup run starts. + start: https://example.com/monitor/start + # Fetched when a backup run completes successfully. + success: https://example.com/monitor/success + # Fetched when a backup run completes with one or more errors. + failure: https://example.com/monitor/failure + +backups: + - kind: github/repo + from: user + to: /backup/github + credentials: !Token your_access_token +``` + +A run is reported as a `failure` if any backup policy reports one or more errors, +and as a `success` otherwise. + +::: tip +Reporting is best-effort. If the monitoring service can't be reached, a warning is +logged but the backup run itself is never affected, ensuring that a flaky monitor +can't cause an otherwise healthy backup to be reported as failed. +::: + +## Examples + +### Sentry +[Sentry's Cron Monitors](https://docs.sentry.io/product/crons/getting-started/http/) +expose a check-in URL which accepts a `status` query parameter. You can point each +state at the same URL while varying the `status` value to report the lifecycle of +your backups. + +```yaml +monitor: + start: https://sentry.io/api/0/organizations/your-org/monitors/github-backup/checkins/?status=in_progress + success: https://sentry.io/api/0/organizations/your-org/monitors/github-backup/checkins/?status=ok + failure: https://sentry.io/api/0/organizations/your-org/monitors/github-backup/checkins/?status=error +``` + +### healthchecks.io +[healthchecks.io](https://healthchecks.io/) provides a base ping URL, with +`/start` and `/fail` suffixes used to signal the start and failure of a run. + +```yaml +monitor: + start: https://hc-ping.com/your-uuid/start + success: https://hc-ping.com/your-uuid + failure: https://hc-ping.com/your-uuid/fail +``` diff --git a/examples/config.yaml b/examples/config.yaml index e067adc..4279c0f 100644 --- a/examples/config.yaml +++ b/examples/config.yaml @@ -1,5 +1,14 @@ schedule: "0 * * * *" +# Optionally report the status of each backup run to an HTTP-based cron +# monitoring service (such as Sentry Crons or healthchecks.io). Each URL is +# fetched with a simple HTTP GET request when the corresponding state is +# reached, and any state you leave out is simply not reported. +monitor: + start: https://sentry.io/api/0/organizations/your-org/monitors/github-backup/checkins/?status=in_progress + success: https://sentry.io/api/0/organizations/your-org/monitors/github-backup/checkins/?status=ok + failure: https://sentry.io/api/0/organizations/your-org/monitors/github-backup/checkins/?status=error + backups: # Backup all the repositories that the provided credentials have access to - kind: github/repo diff --git a/src/config.rs b/src/config.rs index 9b84221..60ad9db 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,13 +2,16 @@ use human_errors::ResultExt; use serde::{Deserialize, Deserializer}; use std::str::FromStr; -use crate::{Args, policy::BackupPolicy}; +use crate::{Args, monitor::MonitorConfig, policy::BackupPolicy}; #[derive(Deserialize)] pub struct Config { #[serde(default, deserialize_with = "deserialize_cron")] pub schedule: Option, + #[serde(default)] + pub monitor: MonitorConfig, + #[serde(default)] pub backups: Vec, } @@ -63,6 +66,38 @@ mod tests { assert!(config.schedule.is_none()); } + #[test] + fn deserialize_monitor_not_provided() { + let config: Config = serde_yaml::from_str("").unwrap(); + assert_eq!(config.monitor, crate::monitor::MonitorConfig::default()); + } + + #[test] + fn deserialize_monitor() { + let config: Config = serde_yaml::from_str( + r#" + monitor: + start: https://example.com/start + success: https://example.com/success + failure: https://example.com/failure + "#, + ) + .unwrap(); + + assert_eq!( + config.monitor.start.as_deref(), + Some("https://example.com/start") + ); + assert_eq!( + config.monitor.success.as_deref(), + Some("https://example.com/success") + ); + assert_eq!( + config.monitor.failure.as_deref(), + Some("https://example.com/failure") + ); + } + #[test] #[cfg_attr(feature = "pure_tests", ignore)] fn deserialize_example_config() { diff --git a/src/main.rs b/src/main.rs index 3505675..c6bf609 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,9 @@ use clap::Parser; use engines::BackupState; use human_errors::Error; +use monitor::Monitor; use pairing::PairingHandler; -use std::sync::atomic::AtomicBool; +use std::sync::atomic::{AtomicBool, AtomicUsize}; use std::time::Duration; use tracing_batteries::prelude::*; use tracing_batteries::{OpenTelemetry, Session, Umami}; @@ -15,6 +16,7 @@ mod engines; mod entities; mod errors; pub(crate) mod helpers; +mod monitor; mod pairing; mod policy; mod sources; @@ -51,6 +53,8 @@ pub struct Args { async fn run(args: Args, session: &Session) -> Result<(), Error> { let config = config::Config::try_from(&args)?; + let monitor = Monitor::new(config.monitor.clone()); + let github_repo = pairing::Pairing::new( sources::GitHubRepoSource::default(), engines::RepoEngine::new(), @@ -78,6 +82,10 @@ async fn run(args: Args, session: &Session) -> Result<(), Error> { .as_ref() .and_then(|s| s.find_next_occurrence(&chrono::Utc::now(), false).ok()); + let handler = LoggingPairingHandler::default(); + + monitor.on_start().await; + { let _span = info_span!("backup.all").entered(); @@ -88,21 +96,15 @@ async fn run(args: Args, session: &Session) -> Result<(), Error> { match policy.kind.as_str() { k if k == GitHubArtifactKind::Repo.as_str() => { info!("Backing up repositories for {}", &policy); - github_repo - .run(policy, &LoggingPairingHandler, &CANCEL) - .await; + github_repo.run(policy, &handler, &CANCEL).await; } k if k == GitHubArtifactKind::Release.as_str() => { info!("Backing up release artifacts for {}", &policy); - github_release - .run(policy, &LoggingPairingHandler, &CANCEL) - .await; + github_release.run(policy, &handler, &CANCEL).await; } k if k == GitHubArtifactKind::Gist.as_str() => { info!("Backing up gist artifacts for {}", &policy); - github_gist - .run(policy, &LoggingPairingHandler, &CANCEL) - .await; + github_gist.run(policy, &handler, &CANCEL).await; } _ => { error!("Unknown policy kind: {}", policy.kind); @@ -112,9 +114,17 @@ async fn run(args: Args, session: &Session) -> Result<(), Error> { } if CANCEL.load(std::sync::atomic::Ordering::Relaxed) { + // The run was interrupted (e.g. by SIGINT), so we deliberately avoid + // reporting either success or failure to the cron monitor. break; } + if handler.errors() > 0 { + monitor.on_failure().await; + } else { + monitor.on_success().await; + } + if let Some(next_run) = next_run { info!("Next backup scheduled for: {}", next_run); @@ -131,7 +141,19 @@ async fn run(args: Args, session: &Session) -> Result<(), Error> { Ok(()) } -pub struct LoggingPairingHandler; +#[derive(Default)] +pub struct LoggingPairingHandler { + errors: AtomicUsize, +} + +impl LoggingPairingHandler { + /// The total number of errors observed across every policy reported to this + /// handler, used to decide whether a backup run should be reported as a + /// success or a failure to the cron monitor. + fn errors(&self) -> usize { + self.errors.load(std::sync::atomic::Ordering::Relaxed) + } +} impl PairingHandler for LoggingPairingHandler { fn on_complete(&self, entity: E, state: BackupState) { @@ -144,6 +166,8 @@ impl PairingHandler for LoggingPairingHandler { } fn on_error(&self, error: Error) { + self.errors + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); warn!("Error: {}", error); } diff --git a/src/monitor.rs b/src/monitor.rs new file mode 100644 index 0000000..3a2e6d3 --- /dev/null +++ b/src/monitor.rs @@ -0,0 +1,176 @@ +use serde::Deserialize; +use tracing_batteries::prelude::*; + +/// Configuration for an HTTP-based cron monitoring solution (such as +/// [Sentry Cron Monitors](https://docs.sentry.io/product/crons/) or +/// [healthchecks.io](https://healthchecks.io)). +/// +/// Each field holds an optional URL which will be fetched (via an HTTP `GET` +/// request) when the corresponding state is reached during a scheduled backup +/// run. Any field which is left unset is simply skipped, allowing you to report +/// only the states you care about. +#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)] +pub struct MonitorConfig { + /// The URL to fetch when a backup run starts. + #[serde(default)] + pub start: Option, + + /// The URL to fetch when a backup run completes successfully. + #[serde(default)] + pub success: Option, + + /// The URL to fetch when a backup run completes with one or more errors. + #[serde(default)] + pub failure: Option, +} + +/// Reports the lifecycle of a backup run to an HTTP-based cron monitoring +/// service by issuing simple `GET` requests to the URLs configured in +/// [`MonitorConfig`]. +/// +/// Reporting is best-effort: failures to reach the monitoring service are +/// logged but never propagated, ensuring that a flaky monitor can never cause +/// an otherwise healthy backup run to be reported as failed. +pub struct Monitor { + config: MonitorConfig, + client: reqwest::Client, +} + +impl Monitor { + pub fn new(config: MonitorConfig) -> Self { + Self { + config, + client: reqwest::Client::new(), + } + } + + /// Report that a backup run has started. + pub async fn on_start(&self) { + self.ping("start", self.config.start.as_deref()).await; + } + + /// Report that a backup run has completed successfully. + pub async fn on_success(&self) { + self.ping("success", self.config.success.as_deref()).await; + } + + /// Report that a backup run has completed with one or more errors. + pub async fn on_failure(&self) { + self.ping("failure", self.config.failure.as_deref()).await; + } + + #[tracing::instrument(skip(self, url), fields(monitor.state = state))] + async fn ping(&self, state: &str, url: Option<&str>) { + let Some(url) = url else { + return; + }; + + debug!("Reporting '{state}' state to cron monitor."); + + match self + .client + .get(url) + .header("User-Agent", "SierraSoftworks/github-backup") + .send() + .await + { + Ok(resp) if resp.status().is_success() => { + debug!("Successfully reported '{state}' state to cron monitor."); + } + Ok(resp) => { + warn!( + "Cron monitor returned HTTP {} when reporting the '{state}' state.", + resp.status() + ); + } + Err(e) => { + warn!("Failed to report the '{state}' state to the cron monitor: {e}"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[test] + fn deserialize_empty() { + let config: MonitorConfig = serde_yaml::from_str("{}").unwrap(); + assert_eq!(config, MonitorConfig::default()); + } + + #[test] + fn deserialize_partial() { + let config: MonitorConfig = + serde_yaml::from_str("start: https://example.com/start").unwrap(); + assert_eq!(config.start.as_deref(), Some("https://example.com/start")); + assert_eq!(config.success, None); + assert_eq!(config.failure, None); + } + + #[tokio::test] + async fn reports_each_state() { + let server = MockServer::start().await; + + for state in ["start", "success", "failure"] { + Mock::given(method("GET")) + .and(path(format!("/{state}"))) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + } + + let monitor = Monitor::new(MonitorConfig { + start: Some(format!("{}/start", server.uri())), + success: Some(format!("{}/success", server.uri())), + failure: Some(format!("{}/failure", server.uri())), + }); + + monitor.on_start().await; + monitor.on_success().await; + monitor.on_failure().await; + + // `MockServer` verifies the `.expect(1)` expectations when dropped. + } + + #[tokio::test] + async fn unconfigured_states_make_no_request() { + let server = MockServer::start().await; + + // Any request reaching the server would fail the `expect(0)` guard. + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let monitor = Monitor::new(MonitorConfig::default()); + + monitor.on_start().await; + monitor.on_success().await; + monitor.on_failure().await; + } + + #[tokio::test] + async fn error_responses_are_swallowed() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(500)) + .expect(1) + .mount(&server) + .await; + + let monitor = Monitor::new(MonitorConfig { + success: Some(format!("{}/success", server.uri())), + ..Default::default() + }); + + // This must not panic even though the monitor returned an error status. + monitor.on_success().await; + } +} From 2f872137d440115ec7a4a6f0f90b3f3260bc324f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 00:30:54 +0000 Subject: [PATCH 2/2] Rename the cron monitoring config key to `ping` Rename the top-level `monitor` configuration key to `ping`, along with the supporting `MonitorConfig`/`Monitor` types (now `PingConfig`/`Pinger`) and the `monitor` module (now `ping`), so the configuration reads more naturally. Also add a unit test covering the backup run's error tracking, which drives the decision of whether a run is reported to the cron monitor as a success or a failure. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Tzz3yuCYDQ2ghxW81e6FMS --- README.md | 4 ++-- docs/guide/monitors.md | 8 ++++---- examples/config.yaml | 2 +- src/config.rs | 18 ++++++++-------- src/main.rs | 35 +++++++++++++++++++++++++------ src/{monitor.rs => ping.rs} | 41 ++++++++++++++++++------------------- 6 files changed, 65 insertions(+), 43 deletions(-) rename src/{monitor.rs => ping.rs} (84%) diff --git a/README.md b/README.md index 1c21f0f..6fe7934 100644 --- a/README.md +++ b/README.md @@ -178,13 +178,13 @@ state of each scheduled run to an HTTP-based cron monitoring service such as [Sentry Cron Monitors](https://docs.sentry.io/product/crons/) or [healthchecks.io](https://healthchecks.io/). -Monitoring is configured under the top-level `monitor` key, where you can provide +Monitoring is configured under the top-level `ping` key, where you can provide a separate URL for each state you care about. Each URL is fetched with a simple HTTP `GET` request when the corresponding state is reached, and any state you omit is simply not reported. ```yaml -monitor: +ping: # Fetched when a backup run starts. start: https://sentry.io/api/0/organizations/your-org/monitors/github-backup/checkins/?status=in_progress # Fetched when a backup run completes successfully. diff --git a/docs/guide/monitors.md b/docs/guide/monitors.md index 75553c0..1cb8d2a 100644 --- a/docs/guide/monitors.md +++ b/docs/guide/monitors.md @@ -11,14 +11,14 @@ monitoring service to track whether your backups are running as expected and to alert you if they stop. ## Configuration -Monitoring is configured under the top-level `monitor` key in your configuration +Monitoring is configured under the top-level `ping` key in your configuration file. You may provide a separate URL for each of the `start`, `success`, and `failure` states, and any state you leave out is simply not reported. ```yaml schedule: "0 * * * *" -monitor: +ping: # Fetched when a backup run starts. start: https://example.com/monitor/start # Fetched when a backup run completes successfully. @@ -51,7 +51,7 @@ state at the same URL while varying the `status` value to report the lifecycle o your backups. ```yaml -monitor: +ping: start: https://sentry.io/api/0/organizations/your-org/monitors/github-backup/checkins/?status=in_progress success: https://sentry.io/api/0/organizations/your-org/monitors/github-backup/checkins/?status=ok failure: https://sentry.io/api/0/organizations/your-org/monitors/github-backup/checkins/?status=error @@ -62,7 +62,7 @@ monitor: `/start` and `/fail` suffixes used to signal the start and failure of a run. ```yaml -monitor: +ping: start: https://hc-ping.com/your-uuid/start success: https://hc-ping.com/your-uuid failure: https://hc-ping.com/your-uuid/fail diff --git a/examples/config.yaml b/examples/config.yaml index 4279c0f..39b362d 100644 --- a/examples/config.yaml +++ b/examples/config.yaml @@ -4,7 +4,7 @@ schedule: "0 * * * *" # monitoring service (such as Sentry Crons or healthchecks.io). Each URL is # fetched with a simple HTTP GET request when the corresponding state is # reached, and any state you leave out is simply not reported. -monitor: +ping: start: https://sentry.io/api/0/organizations/your-org/monitors/github-backup/checkins/?status=in_progress success: https://sentry.io/api/0/organizations/your-org/monitors/github-backup/checkins/?status=ok failure: https://sentry.io/api/0/organizations/your-org/monitors/github-backup/checkins/?status=error diff --git a/src/config.rs b/src/config.rs index 60ad9db..8798d32 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,7 +2,7 @@ use human_errors::ResultExt; use serde::{Deserialize, Deserializer}; use std::str::FromStr; -use crate::{Args, monitor::MonitorConfig, policy::BackupPolicy}; +use crate::{Args, ping::PingConfig, policy::BackupPolicy}; #[derive(Deserialize)] pub struct Config { @@ -10,7 +10,7 @@ pub struct Config { pub schedule: Option, #[serde(default)] - pub monitor: MonitorConfig, + pub ping: PingConfig, #[serde(default)] pub backups: Vec, @@ -67,16 +67,16 @@ mod tests { } #[test] - fn deserialize_monitor_not_provided() { + fn deserialize_ping_not_provided() { let config: Config = serde_yaml::from_str("").unwrap(); - assert_eq!(config.monitor, crate::monitor::MonitorConfig::default()); + assert_eq!(config.ping, crate::ping::PingConfig::default()); } #[test] - fn deserialize_monitor() { + fn deserialize_ping() { let config: Config = serde_yaml::from_str( r#" - monitor: + ping: start: https://example.com/start success: https://example.com/success failure: https://example.com/failure @@ -85,15 +85,15 @@ mod tests { .unwrap(); assert_eq!( - config.monitor.start.as_deref(), + config.ping.start.as_deref(), Some("https://example.com/start") ); assert_eq!( - config.monitor.success.as_deref(), + config.ping.success.as_deref(), Some("https://example.com/success") ); assert_eq!( - config.monitor.failure.as_deref(), + config.ping.failure.as_deref(), Some("https://example.com/failure") ); } diff --git a/src/main.rs b/src/main.rs index c6bf609..d0ba53b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,8 @@ use clap::Parser; use engines::BackupState; use human_errors::Error; -use monitor::Monitor; use pairing::PairingHandler; +use ping::Pinger; use std::sync::atomic::{AtomicBool, AtomicUsize}; use std::time::Duration; use tracing_batteries::prelude::*; @@ -16,8 +16,8 @@ mod engines; mod entities; mod errors; pub(crate) mod helpers; -mod monitor; mod pairing; +mod ping; mod policy; mod sources; mod target; @@ -53,7 +53,7 @@ pub struct Args { async fn run(args: Args, session: &Session) -> Result<(), Error> { let config = config::Config::try_from(&args)?; - let monitor = Monitor::new(config.monitor.clone()); + let pinger = Pinger::new(config.ping.clone()); let github_repo = pairing::Pairing::new( sources::GitHubRepoSource::default(), @@ -84,7 +84,7 @@ async fn run(args: Args, session: &Session) -> Result<(), Error> { let handler = LoggingPairingHandler::default(); - monitor.on_start().await; + pinger.on_start().await; { let _span = info_span!("backup.all").entered(); @@ -120,9 +120,9 @@ async fn run(args: Args, session: &Session) -> Result<(), Error> { } if handler.errors() > 0 { - monitor.on_failure().await; + pinger.on_failure().await; } else { - monitor.on_success().await; + pinger.on_success().await; } if let Some(next_run) = next_run { @@ -210,3 +210,26 @@ async fn main() { session.shutdown(); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::entities::GitRepo; + + #[test] + fn logging_handler_counts_errors() { + let handler = LoggingPairingHandler::default(); + assert_eq!(handler.errors(), 0); + + // Each reported error should be accumulated so that a run with any + // failures can be reported to the cron monitor as a failure. + PairingHandler::::on_error(&handler, human_errors::user("boom", &[])); + PairingHandler::::on_error(&handler, human_errors::user("boom", &[])); + assert_eq!(handler.errors(), 2); + + // Successful completions must not affect the error count. + let repo = GitRepo::new("octocat/Hello-World", "https://example.com/repo.git", None); + handler.on_complete(repo, BackupState::Skipped); + assert_eq!(handler.errors(), 2); + } +} diff --git a/src/monitor.rs b/src/ping.rs similarity index 84% rename from src/monitor.rs rename to src/ping.rs index 3a2e6d3..551dd47 100644 --- a/src/monitor.rs +++ b/src/ping.rs @@ -10,7 +10,7 @@ use tracing_batteries::prelude::*; /// run. Any field which is left unset is simply skipped, allowing you to report /// only the states you care about. #[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)] -pub struct MonitorConfig { +pub struct PingConfig { /// The URL to fetch when a backup run starts. #[serde(default)] pub start: Option, @@ -26,18 +26,18 @@ pub struct MonitorConfig { /// Reports the lifecycle of a backup run to an HTTP-based cron monitoring /// service by issuing simple `GET` requests to the URLs configured in -/// [`MonitorConfig`]. +/// [`PingConfig`]. /// /// Reporting is best-effort: failures to reach the monitoring service are /// logged but never propagated, ensuring that a flaky monitor can never cause /// an otherwise healthy backup run to be reported as failed. -pub struct Monitor { - config: MonitorConfig, +pub struct Pinger { + config: PingConfig, client: reqwest::Client, } -impl Monitor { - pub fn new(config: MonitorConfig) -> Self { +impl Pinger { + pub fn new(config: PingConfig) -> Self { Self { config, client: reqwest::Client::new(), @@ -59,7 +59,7 @@ impl Monitor { self.ping("failure", self.config.failure.as_deref()).await; } - #[tracing::instrument(skip(self, url), fields(monitor.state = state))] + #[tracing::instrument(skip(self, url), fields(ping.state = state))] async fn ping(&self, state: &str, url: Option<&str>) { let Some(url) = url else { return; @@ -98,14 +98,13 @@ mod tests { #[test] fn deserialize_empty() { - let config: MonitorConfig = serde_yaml::from_str("{}").unwrap(); - assert_eq!(config, MonitorConfig::default()); + let config: PingConfig = serde_yaml::from_str("{}").unwrap(); + assert_eq!(config, PingConfig::default()); } #[test] fn deserialize_partial() { - let config: MonitorConfig = - serde_yaml::from_str("start: https://example.com/start").unwrap(); + let config: PingConfig = serde_yaml::from_str("start: https://example.com/start").unwrap(); assert_eq!(config.start.as_deref(), Some("https://example.com/start")); assert_eq!(config.success, None); assert_eq!(config.failure, None); @@ -124,15 +123,15 @@ mod tests { .await; } - let monitor = Monitor::new(MonitorConfig { + let pinger = Pinger::new(PingConfig { start: Some(format!("{}/start", server.uri())), success: Some(format!("{}/success", server.uri())), failure: Some(format!("{}/failure", server.uri())), }); - monitor.on_start().await; - monitor.on_success().await; - monitor.on_failure().await; + pinger.on_start().await; + pinger.on_success().await; + pinger.on_failure().await; // `MockServer` verifies the `.expect(1)` expectations when dropped. } @@ -148,11 +147,11 @@ mod tests { .mount(&server) .await; - let monitor = Monitor::new(MonitorConfig::default()); + let pinger = Pinger::new(PingConfig::default()); - monitor.on_start().await; - monitor.on_success().await; - monitor.on_failure().await; + pinger.on_start().await; + pinger.on_success().await; + pinger.on_failure().await; } #[tokio::test] @@ -165,12 +164,12 @@ mod tests { .mount(&server) .await; - let monitor = Monitor::new(MonitorConfig { + let pinger = Pinger::new(PingConfig { success: Some(format!("{}/success", server.uri())), ..Default::default() }); // This must not panic even though the monitor returned an error status. - monitor.on_success().await; + pinger.on_success().await; } }