pub mod rules;
pub mod sinks;
use core::fmt;
use core::future::Future;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use serde::Deserialize;
use shep_client::dogs::DogConfig;
use shep_client::{LinkLost, RequestError};
use shep_core::barks::{self, SinkOutcome};
use shep_core::protocol::{BusEvent, ProcessInfo};
use shep_core::values::UpDuration;
use tokio::sync::Mutex;
use tokio::time::MissedTickBehavior;
use self::rules::{Firing, Rule, Rules};
use self::sinks::Sink;
use crate::exit::ExitCode;
#[derive(Debug, Clone, PartialEq, Deserialize, schemars::JsonSchema, DogConfig)]
#[serde(deny_unknown_fields, default)]
pub struct BarkConfig {
#[shep(secret)]
pub sinks: BTreeMap<String, Sink>,
pub rules: Vec<Rule>,
pub poll: UpDuration,
pub history_bytes: u64,
pub sink_timeout: UpDuration,
}
impl Default for BarkConfig {
fn default() -> Self {
Self {
sinks: BTreeMap::new(),
rules: Vec::new(),
poll: UpDuration::from_millis(30_000),
history_bytes: barks::DEFAULT_MAX_BYTES,
sink_timeout: UpDuration::from_millis(10_000),
}
}
}
pub trait EventSource: Send {
fn next(&mut self) -> impl Future<Output = Option<Result<BusEvent, u64>>> + Send;
fn resubscribe(&mut self) -> impl Future<Output = Result<(), Resubscribe>> + Send;
}
#[derive(Debug)]
#[must_use = "which of the two it was decides how the dog exits"]
pub enum Resubscribe {
Lost(LinkLost),
Request(RequestError),
}
impl fmt::Display for Resubscribe {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Lost(lost) => lost.fmt(f),
Self::Request(err) => write!(f, "the subscription request failed: {err}"),
}
}
}
impl core::error::Error for Resubscribe {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Lost(lost) => Some(lost),
Self::Request(err) => Some(err),
}
}
}
pub trait FlockSource: Send + Sync {
fn flock(&self) -> impl Future<Output = Result<Vec<ProcessInfo>, RequestError>> + Send;
}
pub trait ConfigSource: Send + Sync {
fn section(&self) -> impl Future<Output = Result<String, RequestError>> + Send;
}
pub fn rules_for(config: &BarkConfig) -> Result<Rules, rules::RulesError> {
let rule_list = if config.rules.is_empty() {
Rules::default_rules(&config.sinks)
} else {
config.rules.clone()
};
Rules::new(rule_list, &config.sinks)
}
#[derive(Debug, Clone)]
struct Delivery {
sinks: Arc<BTreeMap<String, Sink>>,
append_lock: Arc<Mutex<()>>,
barks_path: Arc<PathBuf>,
sink_timeout: Duration,
max_bytes: u64,
}
fn poll_timer(period: Duration) -> tokio::time::Interval {
let mut interval = tokio::time::interval_at(tokio::time::Instant::now() + period, period);
interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
interval
}
pub fn run_loop<E: EventSource, F: FlockSource, C: ConfigSource>(
events: E,
flock: F,
rules: Rules,
config: &BarkConfig,
barks_path: &Path,
config_source: C,
) -> impl Future<Output = ExitCode> + Send + use<E, F, C> {
let sinks = Arc::new(config.sinks.clone());
let sink_timeout = config.sink_timeout.as_duration();
let max_bytes = config.history_bytes;
let mut poll_period = config.poll.as_duration();
let barks_path = Arc::new(barks_path.to_path_buf());
async move {
let mut events = events;
let mut rules = rules;
let mut delivery = Delivery {
sinks,
append_lock: Arc::new(Mutex::new(())),
barks_path,
sink_timeout,
max_bytes,
};
let mut sigterm = match crate::shutdown::Terminate::install() {
Ok(sigterm) => sigterm,
Err(err) => {
eprintln!("shep dog bark: could not install a shutdown handler: {err}");
return ExitCode::Failure;
}
};
let mut poll_interval = poll_timer(poll_period);
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => break ExitCode::Success,
_ = sigterm.recv() => break ExitCode::Success,
next = events.next() => {
match next {
None => match events.resubscribe().await {
Ok(()) => {
reconcile(&flock, &mut rules, &delivery).await;
}
Err(failed) => {
eprintln!("shep dog bark: {failed}");
break match &failed {
Resubscribe::Lost(lost) => super::exit_for(lost),
Resubscribe::Request(err) => ExitCode::from(err),
};
}
},
Some(Ok(BusEvent::DogConfigChanged { .. })) => {
if let Some((next, next_rules)) = reloaded_config(&config_source).await {
delivery.sinks = Arc::new(next.sinks.clone());
delivery.sink_timeout = next.sink_timeout.as_duration();
delivery.max_bytes = next.history_bytes;
rules = next_rules;
if next.poll.as_duration() != poll_period {
poll_period = next.poll.as_duration();
poll_interval = poll_timer(poll_period);
}
eprintln!("shep dog bark: reloaded [bark] from dogs.toml");
}
}
Some(Ok(event)) => {
let firings = rules.on_event(&event, now_ms());
spawn_firings(firings, &delivery);
}
Some(Err(_dropped)) => {
reconcile(&flock, &mut rules, &delivery).await;
}
}
}
_ = poll_interval.tick() => {
reconcile(&flock, &mut rules, &delivery).await;
}
}
}
}
}
async fn reloaded_config<C: ConfigSource>(source: &C) -> Option<(BarkConfig, Rules)> {
let section = match source.section().await {
Ok(section) => section,
Err(err) => {
eprintln!("shep dog bark: could not re-read [bark] from the shepherd: {err}");
return None;
}
};
let config = if section.is_empty() {
BarkConfig::default()
} else {
match toml::from_str::<BarkConfig>(§ion) {
Ok(config) => config,
Err(_err) => {
eprintln!("shep dog bark: [bark] in dogs.toml does not parse; see `shep dogs`");
return None;
}
}
};
match rules_for(&config) {
Ok(rules) => Some((config, rules)),
Err(err) => {
eprintln!("shep dog bark: keeping the running rules; the new ones are refused: {err}");
None
}
}
}
async fn reconcile<F: FlockSource>(flock: &F, rules: &mut Rules, delivery: &Delivery) {
match flock.flock().await {
Ok(snapshot) => {
let firings = rules.on_poll(&snapshot, now_ms());
spawn_firings(firings, delivery);
}
Err(err) => eprintln!("shep dog bark: reconciliation poll failed: {err}"),
}
}
fn spawn_firings(firings: Vec<Firing>, delivery: &Delivery) {
for firing in firings {
let delivery = delivery.clone();
tokio::spawn(async move {
deliver_and_record(firing, &delivery).await;
});
}
}
async fn deliver_and_record(firing: Firing, delivery: &Delivery) {
let mut bark = firing.bark;
let mut outcomes = Vec::with_capacity(firing.sinks.len());
for name in &firing.sinks {
let outcome = match delivery.sinks.get(name) {
Some(sink) => match sinks::deliver(sink, &bark, delivery.sink_timeout).await {
Ok(()) => SinkOutcome {
sink: name.clone(),
error: None,
},
Err(err) => SinkOutcome {
sink: name.clone(),
error: Some(err.to_string()),
},
},
None => SinkOutcome {
sink: name.clone(),
error: Some("sink not configured".to_owned()),
},
};
outcomes.push(outcome);
}
bark.sinks = outcomes;
let _guard = delivery.append_lock.lock().await;
if let Err(err) = barks::append(&delivery.barks_path, &bark, delivery.max_bytes) {
eprintln!("shep dog bark: could not record a fired bark: {err}");
}
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.map(|elapsed| elapsed.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use std::net::SocketAddr;
use shep_core::barks::Bark;
use shep_core::protocol::{ProcessEventKind, RpcError, RpcErrorCode};
use shep_core::status::ProcStatus;
use tokio::sync::{broadcast, oneshot};
use super::*;
use crate::http::{HttpRequest, read_request, write_response};
#[test]
fn the_bark_schema_marks_the_sinks_map_and_leaves_the_rules_plain() {
let schema = shep_client::dogs::config_schema::<BarkConfig>()
.expect("`sinks` is a property of this type");
let schema = schema.as_value();
assert_eq!(
schema.pointer("/properties/sinks/x-shep-secret"),
Some(&serde_json::Value::Bool(true)),
"every sink holds a webhook URL, which is a bearer credential"
);
assert_eq!(
schema.pointer("/properties/rules/x-shep-secret"),
None,
"a rule names sinks and holds no credential of its own"
);
assert!(
schema.pointer("/$defs/Rule/properties/sinks").is_some(),
"the pointer below is only a check while `Rule` still has this \
shape, so a rename that moved it must fail here first"
);
assert_eq!(
schema.pointer("/$defs/Rule/properties/sinks/x-shep-secret"),
None,
"a rule's sinks are names, and a name is not a credential"
);
}
impl EventSource for broadcast::Receiver<BusEvent> {
async fn next(&mut self) -> Option<Result<BusEvent, u64>> {
match self.recv().await {
Ok(event) => Some(Ok(event)),
Err(broadcast::error::RecvError::Lagged(count)) => Some(Err(count)),
Err(broadcast::error::RecvError::Closed) => None,
}
}
async fn resubscribe(&mut self) -> Result<(), Resubscribe> {
Err(Resubscribe::Lost(LinkLost::Budget {
waited: Duration::ZERO,
}))
}
}
struct HandoverSource {
current: Option<broadcast::Receiver<BusEvent>>,
later: std::collections::VecDeque<broadcast::Receiver<BusEvent>>,
when_gone: LinkLost,
refuses: Option<RpcErrorCode>,
resubscribes: Arc<std::sync::atomic::AtomicU32>,
}
impl HandoverSource {
fn across(
generations: Vec<broadcast::Receiver<BusEvent>>,
when_gone: LinkLost,
) -> (Self, Arc<std::sync::atomic::AtomicU32>) {
Self::across_inner(generations, when_gone, None)
}
fn refusing(
generations: Vec<broadcast::Receiver<BusEvent>>,
code: RpcErrorCode,
) -> (Self, Arc<std::sync::atomic::AtomicU32>) {
Self::across_inner(
generations,
LinkLost::Budget {
waited: Duration::ZERO,
},
Some(code),
)
}
fn across_inner(
generations: Vec<broadcast::Receiver<BusEvent>>,
when_gone: LinkLost,
refuses: Option<RpcErrorCode>,
) -> (Self, Arc<std::sync::atomic::AtomicU32>) {
let mut later: std::collections::VecDeque<_> = generations.into();
let current = later.pop_front();
let resubscribes = Arc::new(std::sync::atomic::AtomicU32::new(0));
(
Self {
current,
later,
when_gone,
refuses,
resubscribes: Arc::clone(&resubscribes),
},
resubscribes,
)
}
}
impl EventSource for HandoverSource {
async fn next(&mut self) -> Option<Result<BusEvent, u64>> {
let stream = self.current.as_mut()?;
match stream.recv().await {
Ok(event) => Some(Ok(event)),
Err(broadcast::error::RecvError::Lagged(count)) => Some(Err(count)),
Err(broadcast::error::RecvError::Closed) => {
self.current = None;
None
}
}
}
async fn resubscribe(&mut self) -> Result<(), Resubscribe> {
self.resubscribes
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if let Some(code) = self.refuses {
return Err(Resubscribe::Request(RequestError::Rpc(RpcError {
code,
message: "this shepherd does not serve that topic".into(),
daemon_version: None,
})));
}
match self.later.pop_front() {
Some(stream) => {
self.current = Some(stream);
Ok(())
}
None => Err(Resubscribe::Lost(self.when_gone.clone())),
}
}
}
#[derive(Clone)]
struct ScriptedFlock {
answer: Arc<Vec<ProcessInfo>>,
calls: Arc<std::sync::atomic::AtomicU32>,
}
impl ScriptedFlock {
fn answering(answer: Vec<ProcessInfo>) -> Self {
Self {
answer: Arc::new(answer),
calls: Arc::new(std::sync::atomic::AtomicU32::new(0)),
}
}
fn calls(&self) -> u32 {
self.calls.load(std::sync::atomic::Ordering::SeqCst)
}
}
impl FlockSource for ScriptedFlock {
async fn flock(&self) -> Result<Vec<ProcessInfo>, RequestError> {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok((*self.answer).clone())
}
}
#[derive(Clone)]
struct ScriptedConfig {
section: Arc<String>,
calls: Arc<std::sync::atomic::AtomicU32>,
}
impl ScriptedConfig {
fn answering(section: String) -> Self {
Self {
section: Arc::new(section),
calls: Arc::new(std::sync::atomic::AtomicU32::new(0)),
}
}
fn calls(&self) -> u32 {
self.calls.load(std::sync::atomic::Ordering::SeqCst)
}
}
impl ConfigSource for ScriptedConfig {
async fn section(&self) -> Result<String, RequestError> {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok((*self.section).clone())
}
}
async fn one_shot_sink(
status: u16,
body: &str,
) -> (SocketAddr, oneshot::Receiver<HttpRequest>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = oneshot::channel();
let body = body.to_string();
tokio::spawn(async move {
let (mut stream, _peer) = listener.accept().await.unwrap();
let req = read_request(&mut stream, Duration::from_secs(5))
.await
.unwrap();
write_response(&mut stream, status, "application/json", body.as_bytes())
.await
.unwrap();
let _ = tx.send(req);
});
(addr, rx)
}
async fn slow_sink() -> (SocketAddr, tokio::sync::oneshot::Receiver<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (connected, rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let (_stream, _peer) = listener.accept().await.unwrap();
let _ = connected.send(());
core::future::pending::<()>().await;
});
(addr, rx)
}
fn base_info(name: &str, status: ProcStatus, restarts: u32) -> ProcessInfo {
ProcessInfo::builder(1, name, status)
.pid(Some(4242))
.restarts(restarts)
.uptime_ms(1_000)
.build()
}
fn errored_info(name: &str, restarts: u32) -> ProcessInfo {
base_info(name, ProcStatus::Errored, restarts)
}
fn process_event(name: &str, kind: ProcessEventKind) -> BusEvent {
BusEvent::Process {
event: kind,
info: base_info(name, ProcStatus::Online, 0),
manually: false,
at_ms: 0,
}
}
fn errored_event(name: &str) -> BusEvent {
process_event(name, ProcessEventKind::Errored)
}
fn json_sink(url: String) -> Sink {
Sink::Json { url, body: None }
}
fn log_event(i: u32) -> BusEvent {
BusEvent::LogOut {
id: i,
line: format!("log line {i}"),
}
}
fn gave_up_rules() -> Rules {
let mut sinks = BTreeMap::new();
sinks.insert(
"ops".to_owned(),
json_sink("http://127.0.0.1:1/hook".to_owned()),
);
Rules::new(
vec![rules::Rule {
when: rules::Trigger::GaveUp {},
sinks: vec!["ops".to_owned()],
debounce: UpDuration::from_millis(5 * 60_000),
}],
&sinks,
)
.unwrap()
}
fn config_with_sink(addr: SocketAddr, _barks_path: &Path) -> BarkConfig {
let mut sinks = BTreeMap::new();
sinks.insert("ops".to_owned(), json_sink(format!("http://{addr}/hook")));
BarkConfig {
sinks,
rules: Vec::new(),
poll: UpDuration::from_millis(60_000),
history_bytes: barks::DEFAULT_MAX_BYTES,
sink_timeout: UpDuration::from_millis(5_000),
}
}
#[tokio::test]
async fn a_dropped_frame_makes_bark_poll_and_catch_up() {
let (tx, mut rx) = tokio::sync::broadcast::channel(4);
for i in 0..64 {
tx.send(log_event(i)).unwrap();
}
tx.send(errored_event("web")).unwrap();
assert!(
matches!(rx.recv().await, Err(broadcast::error::RecvError::Lagged(n)) if n > 0),
"the fixture must actually overflow the channel"
);
let (tx2, rx2) = tokio::sync::broadcast::channel(4);
for i in 0..64 {
tx2.send(log_event(i)).unwrap();
}
tx2.send(errored_event("web")).unwrap();
let (addr, captured) = one_shot_sink(200, "").await;
let dir = tempfile::tempdir().unwrap();
let barks_path = dir.path().join("barks.jsonl");
let flock = ScriptedFlock::answering(vec![errored_info("web", 16)]);
let loop_handle = tokio::spawn(run_loop(
rx2,
flock.clone(),
gave_up_rules(),
&config_with_sink(addr, &barks_path),
&barks_path,
ScriptedConfig::answering(String::new()),
));
let req = tokio::time::timeout(Duration::from_secs(5), captured)
.await
.expect("a dropped frame must produce a delivered bark")
.unwrap();
assert!(String::from_utf8_lossy(&req.body).contains("web"));
let recorded = tokio::time::timeout(Duration::from_secs(5), async {
loop {
let records = shep_core::barks::read(&barks_path).unwrap();
if !records.is_empty() {
break records;
}
tokio::task::yield_now().await;
}
})
.await
.expect("the delivered bark must be recorded promptly after delivery");
assert_eq!(recorded.len(), 1);
assert_eq!(recorded[0].subject, "web");
assert_eq!(recorded[0].sinks[0].error, None);
assert_eq!(
flock.calls(),
1,
"the poll ran because of the lag, not because an interval elapsed: \
the interval is 60s and this test is milliseconds old"
);
loop_handle.abort();
}
#[tokio::test]
async fn a_handover_re_subscribes_instead_of_ending_the_dog() {
let (first_tx, first_rx) = tokio::sync::broadcast::channel(8);
let (second_tx, second_rx) = tokio::sync::broadcast::channel(8);
let (source, resubscribes) = HandoverSource::across(
vec![first_rx, second_rx],
LinkLost::Budget {
waited: Duration::ZERO,
},
);
let (addr, captured) = one_shot_sink(200, "").await;
let dir = tempfile::tempdir().unwrap();
let barks_path = dir.path().join("barks.jsonl");
let flock = ScriptedFlock::answering(Vec::new());
let loop_handle = tokio::spawn(run_loop(
source,
flock.clone(),
gave_up_rules(),
&config_with_sink(addr, &barks_path),
&barks_path,
ScriptedConfig::answering(String::new()),
));
drop(first_tx);
let delivered = tokio::time::timeout(Duration::from_secs(5), async {
loop {
let _ = second_tx.send(errored_event("web"));
tokio::time::sleep(Duration::from_millis(20)).await;
if loop_handle.is_finished() {
panic!("the loop ended on a handover instead of re-subscribing");
}
}
});
let captured = tokio::time::timeout(Duration::from_secs(5), captured);
let request = tokio::select! {
() = async { delivered.await.ok(); } => panic!(
"no bark was delivered within 5s of the handover, so the \
second generation never reached the loop"
),
request = captured => request.expect("a bark must be delivered after the handover"),
};
assert!(String::from_utf8_lossy(&request.unwrap().body).contains("web"));
assert_eq!(
resubscribes.load(std::sync::atomic::Ordering::SeqCst),
1,
"the handover must have been answered by exactly one re-subscribe"
);
assert!(
flock.calls() >= 1,
"a re-subscribe must reconcile: frames sent while there was no \
subscription are gone, and only the shepherd knows the flock now"
);
assert!(
!loop_handle.is_finished(),
"the dog must still be running after a handover"
);
loop_handle.abort();
}
#[tokio::test]
async fn a_dog_rides_out_three_shepherds_in_a_row() {
let (first_tx, first_rx) = tokio::sync::broadcast::channel(8);
let (second_tx, second_rx) = tokio::sync::broadcast::channel(8);
let (third_tx, third_rx) = tokio::sync::broadcast::channel(8);
let (source, resubscribes) = HandoverSource::across(
vec![first_rx, second_rx, third_rx],
LinkLost::Budget {
waited: Duration::ZERO,
},
);
let (addr, captured) = one_shot_sink(200, "").await;
let dir = tempfile::tempdir().unwrap();
let barks_path = dir.path().join("barks.jsonl");
let flock = ScriptedFlock::answering(Vec::new());
let loop_handle = tokio::spawn(run_loop(
source,
flock.clone(),
gave_up_rules(),
&config_with_sink(addr, &barks_path),
&barks_path,
ScriptedConfig::answering(String::new()),
));
drop(first_tx);
let two = tokio::time::timeout(Duration::from_secs(5), async {
while resubscribes.load(std::sync::atomic::Ordering::SeqCst) < 1 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
drop(second_tx);
while resubscribes.load(std::sync::atomic::Ordering::SeqCst) < 2 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await;
assert!(two.is_ok(), "both handovers must be answered within 5s");
let delivered = tokio::time::timeout(Duration::from_secs(5), async {
loop {
let _ = third_tx.send(errored_event("web"));
tokio::time::sleep(Duration::from_millis(20)).await;
assert!(
!loop_handle.is_finished(),
"the dog ended on one of the two handovers"
);
}
});
let captured = tokio::time::timeout(Duration::from_secs(5), captured);
let request = tokio::select! {
() = async { delivered.await.ok(); } => panic!(
"no bark was delivered on the third generation within 5s"
),
request = captured => request.expect("a bark must be delivered after two handovers"),
};
assert!(String::from_utf8_lossy(&request.unwrap().body).contains("web"));
assert_eq!(
resubscribes.load(std::sync::atomic::Ordering::SeqCst),
2,
"two handovers, two re-subscribes, no more"
);
loop_handle.abort();
}
#[tokio::test]
async fn a_shepherd_that_never_comes_back_ends_the_dog() {
let (tx, rx) = tokio::sync::broadcast::channel(8);
let (source, resubscribes) = HandoverSource::across(
vec![rx],
LinkLost::Budget {
waited: Duration::from_secs(5),
},
);
let (addr, _captured) = one_shot_sink(200, "").await;
let dir = tempfile::tempdir().unwrap();
let barks_path = dir.path().join("barks.jsonl");
let loop_handle = tokio::spawn(run_loop(
source,
ScriptedFlock::answering(Vec::new()),
gave_up_rules(),
&config_with_sink(addr, &barks_path),
&barks_path,
ScriptedConfig::answering(String::new()),
));
drop(tx);
let code = tokio::time::timeout(Duration::from_secs(5), loop_handle)
.await
.expect("a dog whose shepherd is gone must exit, not linger")
.unwrap();
assert_eq!(
code,
ExitCode::DaemonUnreachable,
"exiting 0 would read as a dog that finished its work"
);
assert_eq!(
resubscribes.load(std::sync::atomic::Ordering::SeqCst),
1,
"it must have tried once before giving up"
);
}
#[tokio::test]
async fn a_refused_re_subscribe_exits_on_the_refusals_own_code() {
let (tx, rx) = tokio::sync::broadcast::channel(8);
let (source, _resubscribes) = HandoverSource::refusing(vec![rx], RpcErrorCode::Unsupported);
let (addr, _captured) = one_shot_sink(200, "").await;
let dir = tempfile::tempdir().unwrap();
let barks_path = dir.path().join("barks.jsonl");
let loop_handle = tokio::spawn(run_loop(
source,
ScriptedFlock::answering(Vec::new()),
gave_up_rules(),
&config_with_sink(addr, &barks_path),
&barks_path,
ScriptedConfig::answering(String::new()),
));
drop(tx);
let code = tokio::time::timeout(Duration::from_secs(5), loop_handle)
.await
.expect("a refused subscription must end the dog, not be waited out")
.unwrap();
assert_eq!(
code,
ExitCode::Unsupported,
"exiting DaemonUnreachable would name a shepherd that answered"
);
}
#[tokio::test]
async fn a_refused_dog_exits_saying_it_was_refused() {
let (tx, rx) = tokio::sync::broadcast::channel(8);
let (source, _resubscribes) = HandoverSource::across(
vec![rx],
LinkLost::Refused {
daemon_version: Some("0.9.9".into()),
message: "daemon speaks protocol 9, client speaks 8".into(),
},
);
let (addr, _captured) = one_shot_sink(200, "").await;
let dir = tempfile::tempdir().unwrap();
let barks_path = dir.path().join("barks.jsonl");
let loop_handle = tokio::spawn(run_loop(
source,
ScriptedFlock::answering(Vec::new()),
gave_up_rules(),
&config_with_sink(addr, &barks_path),
&barks_path,
ScriptedConfig::answering(String::new()),
));
drop(tx);
let code = tokio::time::timeout(Duration::from_secs(5), loop_handle)
.await
.expect("a refused dog must exit rather than retry")
.unwrap();
assert_eq!(code, ExitCode::ProtocolMismatch);
}
#[tokio::test]
async fn a_bark_is_recorded_even_when_every_sink_refuses_it() {
let (addr, _captured) = one_shot_sink(500, "refused").await;
let dir = tempfile::tempdir().unwrap();
let barks_path = dir.path().join("barks.jsonl");
let mut sinks = BTreeMap::new();
sinks.insert("ops".to_owned(), json_sink(format!("http://{addr}/hook")));
let firing = Firing {
bark: Bark {
at_ms: 1_000,
rule: "gave_up".to_owned(),
subject: "web".to_owned(),
message: "web gave up: restart budget exhausted".to_owned(),
sinks: Vec::new(),
},
sinks: vec!["ops".to_owned()],
};
deliver_and_record(
firing,
&Delivery {
sinks: Arc::new(sinks),
append_lock: Arc::new(Mutex::new(())),
barks_path: Arc::new(barks_path.clone()),
sink_timeout: Duration::from_secs(5),
max_bytes: barks::DEFAULT_MAX_BYTES,
},
)
.await;
let recorded = shep_core::barks::read(&barks_path).unwrap();
assert_eq!(
recorded.len(),
1,
"a refused delivery must still be recorded"
);
assert_eq!(recorded[0].subject, "web");
assert!(
recorded[0].sinks[0].error.is_some(),
"the 500 must be recorded as a failed delivery, not silently dropped"
);
}
#[tokio::test]
async fn a_slow_sink_never_stalls_the_loop() {
let (slow_addr, slow_connected) = slow_sink().await;
let (fast_addr, fast_captured) = one_shot_sink(200, "").await;
let dir = tempfile::tempdir().unwrap();
let barks_path = dir.path().join("barks.jsonl");
let mut sinks = BTreeMap::new();
sinks.insert(
"slow".to_owned(),
json_sink(format!("http://{slow_addr}/hook")),
);
sinks.insert(
"fast".to_owned(),
json_sink(format!("http://{fast_addr}/hook")),
);
let rules = Rules::new(
vec![
rules::Rule {
when: rules::Trigger::GaveUp {},
sinks: vec!["slow".to_owned()],
debounce: UpDuration::from_millis(0),
},
rules::Rule {
when: rules::Trigger::Event {
kinds: vec!["online".to_owned()],
},
sinks: vec!["fast".to_owned()],
debounce: UpDuration::from_millis(0),
},
],
&sinks,
)
.unwrap();
let config = BarkConfig {
sinks,
rules: Vec::new(),
poll: UpDuration::from_millis(60_000),
history_bytes: barks::DEFAULT_MAX_BYTES,
sink_timeout: UpDuration::from_millis(600_000),
};
let (tx, rx) = tokio::sync::broadcast::channel(8);
tx.send(errored_event("web")).unwrap();
tx.send(process_event("api", ProcessEventKind::Online))
.unwrap();
let flock = ScriptedFlock::answering(Vec::new());
let loop_handle = tokio::spawn(run_loop(
rx,
flock,
rules,
&config,
&barks_path,
ScriptedConfig::answering(String::new()),
));
tokio::time::timeout(Duration::from_secs(30), slow_connected)
.await
.expect("the slow sink must be reached at all, or this test proves nothing")
.unwrap();
let req = tokio::time::timeout(Duration::from_secs(30), fast_captured)
.await
.expect(
"the fast sink must be reached while a slow sink is still in \
flight; a slow sink must not stall the loop",
)
.unwrap();
assert_eq!(req.method, "POST");
loop_handle.abort();
}
#[test]
fn an_empty_section_gets_sane_defaults_not_zeros() {
let parsed: BarkConfig = toml::from_str("").unwrap();
assert_eq!(parsed, BarkConfig::default());
assert_eq!(BarkConfig::default().poll.as_millis(), 30_000);
assert_eq!(
BarkConfig::default().history_bytes,
barks::DEFAULT_MAX_BYTES
);
assert_eq!(BarkConfig::default().sink_timeout.as_millis(), 10_000);
}
#[test]
fn the_documented_bark_config_parses_from_toml() {
let toml_str = r#"
[sinks]
oncall = { kind = "discord", url = "https://discord.com/api/webhooks/..." }
audit = { kind = "json", url = "https://example.internal/hook" }
[[rules]]
on = "gave_up"
sinks = ["oncall", "audit"]
[[rules]]
on = "restart_rate"
restarts = 5
within = "2m"
sinks = ["oncall"]
"#;
let config: BarkConfig =
toml::from_str(toml_str).expect("the documented [dog.bark] example must parse");
assert_eq!(config.sinks.len(), 2);
assert_eq!(config.rules.len(), 2);
assert_eq!(config.rules[0].when, rules::Trigger::GaveUp {});
assert_eq!(config.rules[0].sinks, vec!["oncall", "audit"]);
assert_eq!(
config.rules[1].when,
rules::Trigger::RestartRate {
restarts: 5,
within: UpDuration::from_millis(2 * 60_000),
}
);
assert_eq!(config.rules[1].sinks, vec!["oncall"]);
Rules::new(config.rules, &config.sinks).expect("both documented rules route to real sinks");
}
#[tokio::test]
async fn a_config_change_swaps_barks_sinks_in_place() {
let (old_addr, mut old_captured) = one_shot_sink(200, "").await;
let (new_addr, new_captured) = one_shot_sink(200, "").await;
let dir = tempfile::tempdir().unwrap();
let barks_path = dir.path().join("barks.jsonl");
let source = ScriptedConfig::answering(format!(
"[sinks.ops]\nkind = \"json\"\nurl = \"http://{new_addr}/hook\"\n"
));
let (tx, rx) = broadcast::channel(16);
let loop_handle = tokio::spawn(run_loop(
rx,
ScriptedFlock::answering(Vec::new()),
gave_up_rules(),
&config_with_sink(old_addr, &barks_path),
&barks_path,
source.clone(),
));
tx.send(BusEvent::DogConfigChanged {
dog: "bark".to_owned(),
})
.unwrap();
tx.send(errored_event("web")).unwrap();
let req = tokio::time::timeout(Duration::from_secs(5), new_captured)
.await
.expect("the bark must reach the sink the new section names")
.unwrap();
assert!(String::from_utf8_lossy(&req.body).contains("web"));
assert_eq!(source.calls(), 1, "one frame, one re-ask");
assert!(
!loop_handle.is_finished(),
"bark swaps its config in place; it does not exit to pick one up"
);
assert!(
old_captured.try_recv().is_err(),
"the sink the old section named must be left alone"
);
loop_handle.abort();
}
}