use core::fmt;
use std::collections::BTreeMap;
use serde::Deserialize;
use shep_core::barks::Bark;
use shep_core::protocol::{BusEvent, ProcessEventKind, ProcessInfo};
use shep_core::status::ProcStatus;
use shep_core::values::{MemSize, UpDuration};
use super::sinks::{self, Sink, SinkConfigError};
mod log_match;
pub use log_match::LogStream;
use log_match::{LogRule, Source};
fn default_debounce() -> UpDuration {
UpDuration::from_millis(5 * 60 * 1_000)
}
#[derive(Debug, Clone, PartialEq, Deserialize, schemars::JsonSchema)]
pub struct Rule {
#[serde(flatten)]
pub when: Trigger,
pub sinks: Vec<String>,
#[serde(default = "default_debounce")]
pub debounce: UpDuration,
}
#[derive(Debug, Clone, PartialEq, Deserialize, schemars::JsonSchema)]
#[serde(tag = "on", rename_all = "snake_case", deny_unknown_fields)]
pub enum Trigger {
Event {
kinds: Vec<String>,
},
GaveUp {},
RestartRate {
restarts: u32,
within: UpDuration,
},
MemoryAbove {
bytes: MemSize,
},
LogMatch {
pattern: String,
#[serde(default)]
stream: LogStream,
},
}
fn gave_up_message(name: &str) -> String {
format!("{name} gave up: restart budget exhausted")
}
fn trigger_name(when: &Trigger) -> &'static str {
match when {
Trigger::Event { .. } => "event",
Trigger::GaveUp {} => "gave_up",
Trigger::RestartRate { .. } => "restart_rate",
Trigger::MemoryAbove { .. } => "memory_above",
Trigger::LogMatch { .. } => "log_match",
}
}
fn wire_spelling(kind: ProcessEventKind) -> &'static str {
match kind {
ProcessEventKind::Start => "start",
ProcessEventKind::Online => "online",
ProcessEventKind::Exit => "exit",
ProcessEventKind::Restart => "restart",
ProcessEventKind::Reload => "reload",
ProcessEventKind::Reloaded => "reloaded",
ProcessEventKind::ReloadAbandoned => "reload_abandoned",
ProcessEventKind::Stop => "stop",
ProcessEventKind::Delete => "delete",
ProcessEventKind::Errored => "errored",
_ => "unrecognized",
}
}
fn is_known_kind(kind: &str) -> bool {
!matches!(
serde_json::from_value::<ProcessEventKind>(serde_json::Value::String(kind.to_owned())),
Err(_) | Ok(ProcessEventKind::Unrecognized)
)
}
#[derive(Debug)]
pub enum RulesError {
UnknownSink {
index: usize,
sink: String,
},
NoSinks {
index: usize,
},
UnknownKind {
index: usize,
kind: String,
},
UnusableSink(SinkConfigError),
BadPattern {
index: usize,
reason: String,
},
}
impl fmt::Display for RulesError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnknownSink { index, sink } => write!(
f,
"rule {index} routes to sink \"{sink}\", which [bark.sinks] in dogs.toml does \
not define"
),
Self::NoSinks { index } => write!(f, "rule {index} routes to no sink at all"),
Self::UnknownKind { index, kind } => write!(
f,
"rule {index}'s event trigger names \"{kind}\", which is not an event kind on the wire"
),
Self::UnusableSink(source) => write!(f, "{source}"),
Self::BadPattern { index, reason } => {
write!(f, "rule {index}'s log_match pattern is refused: {reason}")
}
}
}
}
impl core::error::Error for RulesError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::UnusableSink(source) => Some(source),
Self::UnknownSink { .. }
| Self::NoSinks { .. }
| Self::UnknownKind { .. }
| Self::BadPattern { .. } => None,
}
}
}
impl From<SinkConfigError> for RulesError {
fn from(source: SinkConfigError) -> Self {
Self::UnusableSink(source)
}
}
#[derive(Debug, Default)]
struct SubjectState {
last_fired: BTreeMap<usize, u64>,
restart_windows: BTreeMap<usize, (u32, u64)>,
}
fn subject_state<'a>(
subjects: &'a mut BTreeMap<String, SubjectState>,
subject: &str,
) -> &'a mut SubjectState {
if !subjects.contains_key(subject) {
subjects.insert(subject.to_owned(), SubjectState::default());
}
subjects
.get_mut(subject)
.expect("subject key is present: either it was, or the insert above added it")
}
#[derive(Debug)]
pub struct Rules {
rules: Vec<Rule>,
subjects: BTreeMap<String, SubjectState>,
log_rules: Vec<LogRule>,
names: BTreeMap<u32, String>,
}
fn fire_rule(
rules: &[Rule],
subjects: &mut BTreeMap<String, SubjectState>,
idx: usize,
subject: &str,
now_ms: u64,
message: impl FnOnce() -> String,
) -> Option<Firing> {
let debounce = rules[idx].debounce;
let state = subject_state(subjects, subject);
let ready = state
.last_fired
.get(&idx)
.is_none_or(|&last| now_ms.saturating_sub(last) >= debounce.as_millis());
if !ready {
return None;
}
state.last_fired.insert(idx, now_ms);
Some(Firing {
bark: Bark {
at_ms: now_ms,
rule: trigger_name(&rules[idx].when).to_owned(),
subject: subject.to_owned(),
message: message(),
sinks: Vec::new(),
},
sinks: rules[idx].sinks.clone(),
})
}
impl Rules {
pub fn new(rules: Vec<Rule>, sinks: &BTreeMap<String, Sink>) -> Result<Self, RulesError> {
for (name, sink) in sinks {
sinks::require_usable_url(name, sink)?;
}
let mut log_rules = Vec::new();
for (index, rule) in rules.iter().enumerate() {
if rule.sinks.is_empty() {
return Err(RulesError::NoSinks { index });
}
for sink in &rule.sinks {
if !sinks.contains_key(sink) {
return Err(RulesError::UnknownSink {
index,
sink: sink.clone(),
});
}
}
match &rule.when {
Trigger::Event { kinds } => {
for kind in kinds {
if !is_known_kind(kind) {
return Err(RulesError::UnknownKind {
index,
kind: kind.clone(),
});
}
}
}
Trigger::LogMatch { pattern, stream } => {
log_rules.push(LogRule::compile(index, pattern, *stream)?);
}
Trigger::GaveUp {} | Trigger::RestartRate { .. } | Trigger::MemoryAbove { .. } => {}
}
}
Ok(Self {
rules,
subjects: BTreeMap::new(),
log_rules,
names: BTreeMap::new(),
})
}
#[must_use]
pub fn default_rules(sinks: &BTreeMap<String, Sink>) -> Vec<Rule> {
vec![Rule {
when: Trigger::GaveUp {},
sinks: sinks.keys().cloned().collect(),
debounce: default_debounce(),
}]
}
fn fire(
&mut self,
idx: usize,
subject: &str,
now_ms: u64,
message: impl FnOnce() -> String,
) -> Option<Firing> {
fire_rule(
&self.rules,
&mut self.subjects,
idx,
subject,
now_ms,
message,
)
}
fn restart_window_crossed(
&mut self,
idx: usize,
subject: &str,
current_restarts: u32,
threshold: u32,
within: UpDuration,
now_ms: u64,
) -> bool {
let state = subject_state(&mut self.subjects, subject);
let window = state.restart_windows.entry(idx).or_insert((0, now_ms));
if now_ms.saturating_sub(window.1) > within.as_millis() {
*window = (current_restarts, now_ms);
}
current_restarts.saturating_sub(window.0) >= threshold
}
#[must_use]
pub fn on_event(&mut self, event: &BusEvent, now_ms: u64) -> Vec<Firing> {
let BusEvent::Process {
event: kind, info, ..
} = event
else {
return self.on_log_event(event, now_ms);
};
let kind = *kind;
self.note_name(kind, info);
let kind_wire = wire_spelling(kind);
let mut firings = Vec::new();
for idx in 0..self.rules.len() {
let message = match &self.rules[idx].when {
Trigger::Event { kinds } if kinds.iter().any(|k| k == kind_wire) => {
Some(format!("{} {kind_wire}", info.name))
}
Trigger::GaveUp {} if kind == ProcessEventKind::Errored => {
Some(gave_up_message(&info.name))
}
_ => None,
};
let Some(message) = message else { continue };
firings.extend(self.fire(idx, &info.name, now_ms, || message));
}
firings
}
fn on_log_event(&mut self, event: &BusEvent, now_ms: u64) -> Vec<Firing> {
let (id, line, source) = match event {
BusEvent::LogOut { id, line } => (*id, line, Source::Out),
BusEvent::LogErr { id, line } => (*id, line, Source::Err),
_ => return Vec::new(),
};
let mut firings = Vec::new();
for rule in &self.log_rules {
if !rule.matches(source, line) {
continue;
}
let Some(name) = self.names.get(&id) else {
continue;
};
firings.extend(fire_rule(
&self.rules,
&mut self.subjects,
rule.idx,
name,
now_ms,
|| log_match::message(name, source, line),
));
}
firings
}
fn note_name(&mut self, kind: ProcessEventKind, info: &ProcessInfo) {
if self.log_rules.is_empty() {
return;
}
if kind == ProcessEventKind::Delete {
self.names.remove(&info.id);
} else if self.names.get(&info.id) != Some(&info.name) {
self.names.insert(info.id, info.name.clone());
}
}
pub fn learn(&mut self, flock: &[ProcessInfo]) {
if self.log_rules.is_empty() {
return;
}
self.names = flock
.iter()
.map(|info| (info.id, info.name.clone()))
.collect();
}
pub fn take_names_from(&mut self, previous: &mut Self) {
if !self.log_rules.is_empty() {
self.names = core::mem::take(&mut previous.names);
}
}
#[must_use]
pub fn log_topics(&self) -> Vec<String> {
LogRule::topics(&self.log_rules)
}
#[must_use]
pub fn on_poll(&mut self, flock: &[ProcessInfo], now_ms: u64) -> Vec<Firing> {
self.learn(flock);
let mut firings = Vec::new();
for info in flock {
for idx in 0..self.rules.len() {
let trigger = self.rules[idx].when.clone();
let message = match &trigger {
Trigger::Event { .. } | Trigger::LogMatch { .. } => None,
Trigger::GaveUp {} => (info.status == ProcStatus::Errored)
.then(|| gave_up_message(&info.name)),
Trigger::RestartRate { restarts, within } => self
.restart_window_crossed(
idx,
&info.name,
info.restarts,
*restarts,
*within,
now_ms,
)
.then(|| {
format!(
"{} restarted {} times, at or past the {restarts}-within-{within} early warning",
info.name, info.restarts
)
}),
Trigger::MemoryAbove { bytes } => info.memory_bytes.and_then(|used| {
(used >= bytes.bytes()).then(|| {
format!(
"{} memory at {}, at or above the {bytes} limit",
info.name,
MemSize::from_bytes(used)
)
})
}),
};
let Some(message) = message else { continue };
firings.extend(self.fire(idx, &info.name, now_ms, || message));
}
}
firings
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Firing {
pub bark: Bark,
pub sinks: Vec<String>,
}
#[cfg(test)]
mod tests;