Files
arr/crates/arr-daemon/src/broken.rs
T
naps62-yolo ad0eb0d9d3
ci / web (push) Successful in 1m0s
e2e / e2e (push) Successful in 3m41s
ci / rust (push) Successful in 5m42s
feat: ntfy notifications for imported, needs-decision, broken (#96)
2026-08-23 02:04:36 +01:00

221 lines
7.1 KiB
Rust

//! §9.5 *broken* → the operator alone: Prowlarr, Transmission or TMDB
//! unreachable.
//!
//! Edge-triggered: notifies once when an upstream stops answering, and
//! silently re-arms once it answers again. There is no "fixed" notification —
//! DESIGN.md names exactly three events, and a fourth needs a design change,
//! not an issue.
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use arr_db::Db;
use reqwest::Client;
use tokio::sync::Mutex;
use crate::notify::Notifier;
use crate::reconcile::{Action, ActionFuture, Outcome};
const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
/// Where the three upstreams live, and the keys the two that need one.
#[derive(Debug, Clone)]
pub struct Upstreams {
pub prowlarr_url: String,
pub prowlarr_api_key: Option<String>,
pub transmission_url: String,
pub tmdb_url: String,
pub tmdb_api_key: Option<String>,
}
#[derive(Debug)]
pub struct BrokenAction {
http: Client,
upstreams: Upstreams,
notifier: Notifier,
operator_topic: String,
/// Which upstreams are currently notified as broken. Transient — a
/// restart re-probes and re-notifies whatever is still down.
broken: Arc<Mutex<HashSet<&'static str>>>,
}
impl BrokenAction {
#[must_use]
pub fn new(upstreams: Upstreams, notifier: Notifier, operator_topic: String) -> Self {
Self {
http: Client::new(),
upstreams,
notifier,
operator_topic,
broken: Arc::new(Mutex::new(HashSet::new())),
}
}
async fn tick(&self) -> Vec<Outcome> {
let (prowlarr, transmission, tmdb) = tokio::join!(
self.probe_prowlarr(),
self.probe_transmission(),
self.probe_tmdb(),
);
let mut outcomes = Vec::new();
outcomes.extend(self.notify_transition("prowlarr", prowlarr).await);
outcomes.extend(self.notify_transition("transmission", transmission).await);
outcomes.extend(self.notify_transition("tmdb", tmdb).await);
outcomes
}
/// `reachable` is `true` when the upstream answered, or when it needs no
/// key and none is configured (not an outage — see `probe_tmdb`).
async fn notify_transition(&self, name: &'static str, reachable: bool) -> Option<Outcome> {
let mut broken = self.broken.lock().await;
if reachable {
broken.remove(name);
return None;
}
if !broken.insert(name) {
return None;
}
match self
.notifier
.send(
&self.operator_topic,
"arr: broken",
&format!("{name} is unreachable"),
)
.await
{
Ok(()) => Some(Outcome::new(
format!("{name} unreachable"),
"notified operator".to_string(),
)),
Err(error) => {
tracing::warn!(%error, name, "broken notification failed");
None
}
}
}
/// Prowlarr answers `/ping` without a key; the key is sent anyway so a
/// misconfigured one still counts as unreachable rather than silently
/// searching nothing.
async fn probe_prowlarr(&self) -> bool {
let url = format!("{}/ping", self.upstreams.prowlarr_url.trim_end_matches('/'));
let mut request = self.http.get(url).timeout(PROBE_TIMEOUT);
if let Some(key) = &self.upstreams.prowlarr_api_key {
request = request.header("X-Api-Key", key);
}
matches!(request.send().await, Ok(response) if response.status().is_success())
}
/// Transmission answers an RPC call without a session id with `409` plus
/// the id to retry with. That is a live daemon, so it counts as
/// reachable.
async fn probe_transmission(&self) -> bool {
let request = self
.http
.post(&self.upstreams.transmission_url)
.timeout(PROBE_TIMEOUT)
.json(&serde_json::json!({ "method": "session-get" }));
matches!(
request.send().await,
Ok(response)
if response.status().is_success()
|| response.status() == reqwest::StatusCode::CONFLICT
)
}
/// No key configured is not an outage (DESIGN.md §9.5 is about
/// reachability, not setup) — TMDB is simply not probed.
async fn probe_tmdb(&self) -> bool {
let Some(key) = &self.upstreams.tmdb_api_key else {
return true;
};
let url = format!(
"{}/configuration",
self.upstreams.tmdb_url.trim_end_matches('/')
);
matches!(
self.http
.get(url)
.timeout(PROBE_TIMEOUT)
.query(&[("api_key", key)])
.send()
.await,
Ok(response) if response.status().is_success()
)
}
}
impl Action for BrokenAction {
fn name(&self) -> &'static str {
"broken"
}
fn run<'a>(&'a self, _database: &'a Db) -> ActionFuture<'a> {
Box::pin(async move { Ok(self.tick().await) })
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
fn upstreams(prowlarr_url: String, transmission_url: String) -> Upstreams {
Upstreams {
prowlarr_url,
prowlarr_api_key: None,
transmission_url,
tmdb_url: "http://127.0.0.1:1".to_string(),
tmdb_api_key: None,
}
}
#[tokio::test]
async fn an_unreachable_upstream_notifies_once_then_re_arms() {
let prowlarr = MockServer::start().await;
// No mock mounted: every request 404s, which counts as unreachable.
let transmission = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(409))
.mount(&transmission)
.await;
let ntfy = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&ntfy)
.await;
let action = BrokenAction::new(
upstreams(prowlarr.uri(), transmission.uri()),
Notifier::new(ntfy.uri()).unwrap(),
"operator-topic".to_string(),
);
let first = action.tick().await;
let second = action.tick().await;
assert_eq!(first.len(), 1, "notifies on the tick it goes unreachable");
assert_eq!(second.len(), 0, "does not repeat while still broken");
Mock::given(method("GET"))
.and(path("/ping"))
.respond_with(ResponseTemplate::new(200))
.mount(&prowlarr)
.await;
let recovered = action.tick().await;
assert_eq!(recovered.len(), 0, "recovery is silent, no fourth event");
// Take it down again: a fresh outage re-arms and notifies again.
prowlarr.reset().await;
let broken_again = action.tick().await;
assert_eq!(broken_again.len(), 1, "re-arms after recovering");
}
}