#![allow(non_snake_case)]
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tower::dispatch::{DispatchAuditEvent, DispatchEngine, DispatchRecorder};
use tower::event::{Event, EventScope, Level};
use tower::rules::parse::parse_rule_yaml;
use tower::scryer_filter::ScryerFilter;
use tower::supervisor::{EventStream, NoPeers, SubscriptionSource, Supervisor};
use tower::triggers::{NotificationHandler, NotificationPayload, NotificationSink, NotifyError};
use tower_rules::*;
const CANONICAL_RULE_YAML: &str =
include_str!("../../../../.yah/infra/rules/yubaba-quorum-loss.yaml");
struct TestStream(std::sync::mpsc::Receiver<Event>);
impl EventStream for TestStream {
fn try_next(&mut self) -> Option<Event> {
self.0.try_recv().ok()
}
}
struct TestSource {
senders: Mutex<HashMap<RuleId, std::sync::mpsc::Sender<Event>>>,
}
impl TestSource {
fn new() -> Arc<Self> {
Arc::new(Self { senders: Mutex::new(HashMap::new()) })
}
fn send_to(&self, rule_id: &RuleId, event: Event) {
if let Some(tx) = self.senders.lock().unwrap().get(rule_id) {
let _ = tx.send(event);
}
}
}
impl SubscriptionSource for TestSource {
fn subscribe(&self, _filter: ScryerFilter, rule_id: &RuleId) -> Box<dyn EventStream> {
let (tx, rx) = std::sync::mpsc::channel();
self.senders.lock().unwrap().insert(rule_id.clone(), tx);
Box::new(TestStream(rx))
}
}
#[derive(Clone)]
struct RecordingNotificationSink {
calls: Arc<Mutex<Vec<(NotificationChannel, NotificationPayload)>>>,
}
impl RecordingNotificationSink {
fn new() -> Self {
Self { calls: Arc::new(Mutex::new(Vec::new())) }
}
fn calls(&self) -> Vec<(NotificationChannel, NotificationPayload)> {
self.calls.lock().unwrap().clone()
}
}
impl NotificationSink for RecordingNotificationSink {
fn notify(
&self,
channel: &NotificationChannel,
payload: &NotificationPayload,
) -> Result<(), NotifyError> {
self.calls.lock().unwrap().push((channel.clone(), payload.clone()));
Ok(())
}
}
struct NullRecorder;
impl DispatchRecorder for NullRecorder {
fn record(&self, _: DispatchAuditEvent) {}
}
fn setup_supervisor(
) -> (Supervisor, Arc<TestSource>, RuleId) {
let rule = parse_rule_yaml(CANONICAL_RULE_YAML).expect("canonical YAML must parse");
let rule_id = rule.id.clone();
let source = TestSource::new();
let mut sup = Supervisor::new(source.clone(), Arc::new(NoPeers), 256, 100);
sup.load_rule(rule).expect("canonical rule must load into supervisor");
(sup, source, rule_id)
}
fn setup_engine() -> (DispatchEngine, RecordingNotificationSink) {
let sink = RecordingNotificationSink::new();
let handler = NotificationHandler::new(Box::new(sink.clone()));
let engine = DispatchEngine::new(5, Box::new(handler), Box::new(NullRecorder));
(engine, sink)
}
#[test]
fn canonical_rule_parses() {
let rule = parse_rule_yaml(CANONICAL_RULE_YAML).expect("parse must succeed");
assert_eq!(rule.id, RuleId("yubaba-quorum-loss".into()));
assert!(rule.enabled, "canonical rule must be enabled by default");
assert_eq!(rule.debounce_ms, Some(30_000));
assert!(
matches!(rule.trigger, Trigger::Notification { severity: Severity::Critical, .. }),
"trigger must be a Critical notification"
);
if let Trigger::Notification { channels, .. } = &rule.trigger {
assert!(
channels.contains(&NotificationChannel::DesktopBadge),
"DesktopBadge must be a configured channel"
);
}
let warnings = tower::rules::validate::validate(&rule)
.expect("canonical rule must pass validation");
assert!(
warnings.is_empty(),
"canonical rule must produce no validation warnings: {warnings:?}"
);
}
#[test]
fn tower_first_rule__local() {
let (mut sup, source, rule_id) = setup_supervisor();
let (mut engine, sink) = setup_engine();
source.send_to(&rule_id, Event::health(HealthSignal::YubabaRaftQuorumLost, 1));
let (fired, _health) = sup.poll();
assert_eq!(fired.len(), 1, "quorum-loss health signal must fire the rule");
engine.process(fired);
let calls = sink.calls();
assert_eq!(calls.len(), 1, "exactly one notification expected");
assert!(
matches!(calls[0].0, NotificationChannel::DesktopBadge),
"expected DesktopBadge notification channel, got {:?}",
calls[0].0
);
assert_eq!(
calls[0].1.severity,
Severity::Critical,
"quorum loss must be a Critical notification"
);
assert!(
calls[0].1.rule_name.contains("quorum"),
"notification rule_name must reference quorum: {:?}",
calls[0].1.rule_name
);
}
#[test]
fn tower_first_rule__yubaba_event_path() {
let (mut sup, source, rule_id) = setup_supervisor();
let (mut engine, sink) = setup_engine();
source.send_to(
&rule_id,
Event {
scope: EventScope::Service(MeshIdent("yubaba.local".into())),
level: Level::Error,
target: "raft.quorum_lost".into(),
msg: "yubaba raft quorum lost: 1 of 3 peers reachable".into(),
fields: {
let mut m = HashMap::new();
m.insert("peers_reachable".into(), serde_json::json!(1));
m.insert("peers_total".into(), serde_json::json!(3));
m
},
seq: 1,
},
);
let (fired, _health) = sup.poll();
assert_eq!(fired.len(), 1, "yubaba RPC quorum-loss event must fire the rule");
engine.process(fired);
let calls = sink.calls();
assert_eq!(calls.len(), 1, "exactly one notification expected");
assert!(matches!(calls[0].0, NotificationChannel::DesktopBadge));
assert_eq!(calls[0].1.severity, Severity::Critical);
}
#[test]
fn tower_first_rule__dedup_suppresses_replay() {
let (mut sup, source, rule_id) = setup_supervisor();
let (mut engine, sink) = setup_engine();
source.send_to(&rule_id, Event::health(HealthSignal::YubabaRaftQuorumLost, 42));
let (fired, _) = sup.poll();
assert_eq!(fired.len(), 1, "first delivery must fire");
engine.process(fired);
assert_eq!(sink.calls().len(), 1, "first notification dispatched");
source.send_to(&rule_id, Event::health(HealthSignal::YubabaRaftQuorumLost, 42));
let (fired2, _) = sup.poll();
assert_eq!(fired2.len(), 0, "replay of seq=42 must be suppressed by dedup ring");
engine.process(fired2);
assert_eq!(sink.calls().len(), 1, "no second notification on replay");
}
#[test]
fn tower_first_rule__warn_event_does_not_fire() {
let (mut sup, source, rule_id) = setup_supervisor();
let (mut engine, sink) = setup_engine();
source.send_to(
&rule_id,
Event {
scope: EventScope::Service(MeshIdent("yubaba.local".into())),
level: Level::Warn,
target: "raft.quorum_lost".into(),
msg: "quorum approaching threshold".into(),
fields: HashMap::new(),
seq: 1,
},
);
let (fired, _) = sup.poll();
assert_eq!(fired.len(), 0, "warn-level event must not fire the error-gated rule");
engine.process(fired);
assert!(sink.calls().is_empty(), "no notification for warn-level event");
}
#[test]
fn tower_first_rule__unrelated_yubaba_error_does_not_fire() {
let (mut sup, source, rule_id) = setup_supervisor();
let (mut engine, sink) = setup_engine();
source.send_to(
&rule_id,
Event {
scope: EventScope::Service(MeshIdent("yubaba.local".into())),
level: Level::Error,
target: "workload.deploy_failed".into(),
msg: "deploy failed for noisetable-api".into(),
fields: HashMap::new(),
seq: 1,
},
);
let (fired, _) = sup.poll();
assert_eq!(fired.len(), 0, "unrelated error target must not match exact target filter");
engine.process(fired);
assert!(sink.calls().is_empty());
}