use serde::{Deserialize, Serialize};
use crate::protocol::channel::ChildMessage;
use crate::protocol::request::ProcessInfo;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ProcessEventKind {
Start,
Online,
Exit,
Restart,
Reload,
Reloaded,
ReloadAbandoned,
Stop,
Delete,
Errored,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
#[non_exhaustive]
pub enum BusEvent {
Process {
event: ProcessEventKind,
info: ProcessInfo,
manually: bool,
at_ms: u64,
},
LogOut {
id: u32,
line: String,
},
LogErr {
id: u32,
line: String,
},
Channel {
id: u32,
message: ChildMessage,
},
Dropped {
count: u64,
},
DaemonShutdown,
}
impl BusEvent {
#[must_use]
pub fn topic(&self) -> &'static str {
match self {
Self::Process { event, .. } => match event {
ProcessEventKind::Start => "process.start",
ProcessEventKind::Online => "process.online",
ProcessEventKind::Exit => "process.exit",
ProcessEventKind::Restart => "process.restart",
ProcessEventKind::Reload => "process.reload",
ProcessEventKind::Reloaded => "process.reloaded",
ProcessEventKind::ReloadAbandoned => "process.reload_abandoned",
ProcessEventKind::Stop => "process.stop",
ProcessEventKind::Delete => "process.delete",
ProcessEventKind::Errored => "process.errored",
},
Self::LogOut { .. } => "log.out",
Self::LogErr { .. } => "log.err",
Self::Channel { message, .. } => match message {
ChildMessage::Ready => "channel.ready",
ChildMessage::Metric { .. } => "channel.metric",
ChildMessage::ActionReply { .. } => "channel.action_reply",
},
Self::Dropped { .. } => "daemon.dropped",
Self::DaemonShutdown => "daemon.shutdown",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::request::{ExitInfo, ProcessInfo};
use crate::status::ProcStatus;
#[test]
fn bus_event_wire_snapshots() {
let mut events = vec![
BusEvent::Process {
event: ProcessEventKind::Exit,
info: ProcessInfo {
id: 3,
name: "web".to_string(),
status: ProcStatus::WaitingRestart,
pid: None,
restarts: 2,
uptime_ms: 500,
fold: None,
out_file: Some("/home/ada/.shep/logs/web-0-out.log".to_string()),
err_file: Some("/home/ada/.shep/logs/web-0-err.log".to_string()),
cpu_percent: None,
memory_bytes: None,
dog: None,
lambs: None,
last_exit: Some(ExitInfo {
code: Some(1),
signal: None,
}),
smit: Some("\u{25b2} main@a1b2c3".to_string()),
},
manually: false,
at_ms: 1_700_000_000_000,
},
BusEvent::LogOut {
id: 3,
line: "listening on :8080".to_string(),
},
BusEvent::Dropped { count: 17 },
];
let sample = ProcessInfo::builder(3, "web", ProcStatus::WaitingRestart)
.restarts(2)
.uptime_ms(500)
.out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
.err_file(Some("/home/ada/.shep/logs/web-0-err.log".to_string()))
.last_exit(Some(ExitInfo {
code: Some(1),
signal: None,
}))
.build();
let lifecycle = [
ProcessEventKind::Start,
ProcessEventKind::Online,
ProcessEventKind::Restart,
ProcessEventKind::Stop,
ProcessEventKind::Delete,
ProcessEventKind::Errored,
]
.map(|event| BusEvent::Process {
event,
info: sample.clone(),
manually: false,
at_ms: 1_700_000_000_000,
});
events.extend(lifecycle);
events.extend([
BusEvent::Channel {
id: 3,
message: ChildMessage::Ready,
},
BusEvent::Channel {
id: 3,
message: ChildMessage::Metric {
name: "rps".to_string(),
value: 42.0,
},
},
BusEvent::Channel {
id: 3,
message: ChildMessage::ActionReply {
action: "gc".to_string(),
body: "freed 12MB".to_string(),
id: Some(7),
},
},
]);
insta::assert_json_snapshot!("bus_event_wire_v1", events);
}
#[test]
fn topics_follow_the_dotted_grammar() {
let e = BusEvent::LogOut {
id: 1,
line: String::new(),
};
assert_eq!(e.topic(), "log.out");
assert_eq!(BusEvent::DaemonShutdown.topic(), "daemon.shutdown");
}
#[test]
fn a_reload_reports_itself_under_three_topics() {
for (kind, topic, wire) in [
(ProcessEventKind::Reload, "process.reload", "\"reload\""),
(
ProcessEventKind::Reloaded,
"process.reloaded",
"\"reloaded\"",
),
(
ProcessEventKind::ReloadAbandoned,
"process.reload_abandoned",
"\"reload_abandoned\"",
),
] {
let event = BusEvent::Process {
event: kind,
info: ProcessInfo {
id: 3,
name: "web".to_string(),
status: ProcStatus::Stopping,
pid: Some(4242),
restarts: 0,
uptime_ms: 0,
fold: None,
out_file: None,
err_file: None,
cpu_percent: None,
memory_bytes: None,
dog: None,
lambs: None,
last_exit: None,
smit: None,
},
manually: true,
at_ms: 0,
};
assert_eq!(event.topic(), topic, "{kind:?}");
assert_eq!(serde_json::to_string(&kind).unwrap(), wire, "{kind:?}");
}
}
#[test]
fn v1_bus_event_fixture_still_deserializes() {
let fixture = r#"{"event":"log_out","data":{"id":3,"line":"ready"}}"#;
let ev: BusEvent = serde_json::from_str(fixture).unwrap();
assert!(matches!(ev, BusEvent::LogOut { id: 3, .. }));
}
#[test]
fn every_shepherd_channel_message_has_its_own_topic() {
for (message, topic) in [
(ChildMessage::Ready, "channel.ready"),
(
ChildMessage::Metric {
name: "rps".to_string(),
value: 42.0,
},
"channel.metric",
),
(
ChildMessage::ActionReply {
action: "gc".to_string(),
body: "ok".to_string(),
id: Some(7),
},
"channel.action_reply",
),
] {
let event = BusEvent::Channel {
id: 3,
message: message.clone(),
};
assert_eq!(event.topic(), topic, "{message:?}");
}
}
#[test]
fn the_channel_glob_reaches_all_three_topics() {
for message in [
ChildMessage::Ready,
ChildMessage::Metric {
name: "rps".to_string(),
value: 1.0,
},
ChildMessage::ActionReply {
action: "gc".to_string(),
body: String::new(),
id: None,
},
] {
let topic = BusEvent::Channel { id: 1, message }.topic();
assert!(
topic.starts_with("channel."),
"`{topic}` is not under the channel.* glob"
);
}
}
#[test]
fn a_channel_event_carries_the_message_verbatim() {
let event = BusEvent::Channel {
id: 3,
message: ChildMessage::ActionReply {
action: "gc".to_string(),
body: "freed 12MB".to_string(),
id: Some(7),
},
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("freed 12MB"), "{json}");
assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
}
}