shep_core/protocol/events.rs
1//! Bus events broadcast to subscribed clients
2
3use std::borrow::Cow;
4
5use serde::{Deserialize, Serialize};
6
7use crate::protocol::ChildMessage;
8use crate::protocol::request::ProcessInfo;
9
10/// What happened to a sheep
11// wire format: changing existing variants is a breaking change
12//
13// A new variant is not free here: there is no `#[serde(other)]` fallback,
14// so an old subscriber is sent a frame under `process.*` it cannot decode.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17#[non_exhaustive]
18pub enum ProcessEventKind {
19 /// Spawn initiated
20 Start,
21 /// Became ready/online
22 Online,
23 /// Process exited
24 Exit,
25 /// Restart initiated
26 Restart,
27 /// A reload is replacing this instance: its replacement has been spawned
28 /// into the same instance slot, and this one will be asked to go once
29 /// that replacement is serving
30 Reload,
31 /// This instance has replaced the one it was spawned to drain; that one
32 /// is gone.
33 Reloaded,
34 /// A reload gave up, so the instances it had not reached are left alone
35 ///
36 /// The instance named is whichever one the abandonment left holding the
37 /// slot: still the app's live instance if the reload gave up before
38 /// replacing it, or the replacement if that one went down instead.
39 /// `info` reflects that instance's state at event time; read
40 /// `info.status` rather than assume it is live.
41 ReloadAbandoned,
42 /// Stopped by request
43 Stop,
44 /// Deregistered
45 Delete,
46 /// Restart budget exhausted
47 Errored,
48}
49
50/// One event on the daemon bus
51///
52/// Adjacently tagged: `event` discriminator, `data` wrapper. Subscription
53/// topics are the dotted strings from [`BusEvent::topic`] (`process.exit`,
54/// `log.out`, `daemon.*`); the daemon's filter globs against them.
55// wire format: changing existing variants is a breaking change
56//
57// `large_enum_variant` allowed: boxing `Process` would break every match on
58// it, for no benefit since an event is serialized immediately.
59#[allow(clippy::large_enum_variant)]
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61// Adjacently tagged, not internally: `Process`'s own `event` field would
62// collide with an internal tag named `event`, and serde_derive refuses
63// that.
64#[serde(tag = "event", content = "data", rename_all = "snake_case")]
65#[non_exhaustive]
66pub enum BusEvent {
67 /// Lifecycle event for one sheep
68 Process {
69 /// What happened
70 event: ProcessEventKind,
71 /// Sheep snapshot at event time
72 info: ProcessInfo,
73 /// True when a user action caused it
74 manually: bool,
75 /// Unix millis
76 at_ms: u64,
77 },
78 /// One stdout line from a sheep
79 LogOut {
80 /// Sheep id
81 id: u32,
82 /// The line (no trailing newline)
83 line: String,
84 },
85 /// One stderr line from a sheep
86 LogErr {
87 /// Sheep id
88 id: u32,
89 /// The line
90 line: String,
91 },
92 /// One message a sheep wrote on its shepherd channel (fd 3).
93 ///
94 /// Child -> shepherd only: the shepherd's own writes (a shutdown
95 /// message, a dispatched action) are reported elsewhere, by
96 /// `process.stop` and `Response::Triggered`.
97 ///
98 /// `message` is the app's own text, whole and unredacted. The daemon
99 /// adds nothing of its own; app-provided text must be safe for every
100 /// subscriber, since it is broadcast verbatim.
101 Channel {
102 /// The sheep that wrote it.
103 id: u32,
104 /// The message, exactly as it came off fd 3.
105 message: ChildMessage,
106 },
107 /// The bounded queue dropped this many events for this subscriber
108 Dropped {
109 /// Dropped-event count since last notice
110 count: u64,
111 },
112 /// Daemon is shutting down
113 DaemonShutdown,
114 /// A dog's section in `dogs.toml` changed. Published under
115 /// `config.dog.<name>`, so a dog subscribes to its own name and hears
116 /// nobody else's.
117 ///
118 /// Carries only the dog's name, nothing else: the bus is a broadcast,
119 /// and a `[bark]` section can hold a webhook URL as a bearer credential
120 /// (why [`DogSectionToml`] redacts its own `Debug`). A dog that wants
121 /// the values re-asks with
122 /// [`Request::DogConfig`](crate::protocol::Request::DogConfig), which
123 /// answers only that dog's own section.
124 ///
125 /// [`DogSectionToml`]: crate::protocol::DogSectionToml
126 DogConfigChanged {
127 /// The dog whose section changed.
128 dog: String,
129 },
130}
131
132impl BusEvent {
133 /// The dotted subscription topic for this event (spec §6 grammar)
134 ///
135 /// A [`Cow`] rather than a `&'static str`, because one topic is not
136 /// fixed: [`Self::DogConfigChanged`] names its dog in the topic
137 /// itself, which is what lets a dog subscribe to its own config and
138 /// hear nobody else's. Every other variant is still a borrowed
139 /// literal and allocates nothing.
140 #[must_use]
141 pub fn topic(&self) -> Cow<'static, str> {
142 let fixed = match self {
143 Self::Process { event, .. } => match event {
144 ProcessEventKind::Start => "process.start",
145 ProcessEventKind::Online => "process.online",
146 ProcessEventKind::Exit => "process.exit",
147 ProcessEventKind::Restart => "process.restart",
148 ProcessEventKind::Reload => "process.reload",
149 ProcessEventKind::Reloaded => "process.reloaded",
150 ProcessEventKind::ReloadAbandoned => "process.reload_abandoned",
151 ProcessEventKind::Stop => "process.stop",
152 ProcessEventKind::Delete => "process.delete",
153 ProcessEventKind::Errored => "process.errored",
154 },
155 Self::LogOut { .. } => "log.out",
156 Self::LogErr { .. } => "log.err",
157 // Total match over `ChildMessage`: a fourth kind on fd 3 fails
158 // to compile here until its topic is decided.
159 Self::Channel { message, .. } => match message {
160 ChildMessage::Ready => "channel.ready",
161 ChildMessage::Metric { .. } => "channel.metric",
162 ChildMessage::ActionReply { .. } => "channel.action_reply",
163 },
164 Self::Dropped { .. } => "daemon.dropped",
165 Self::DaemonShutdown => "daemon.shutdown",
166 // The one topic built rather than named. `config.dog.` is the
167 // prefix a subscriber globs on; the dog's own name is the last
168 // segment, so `config.dog.bark` reaches one dog and `config.*`
169 // reaches all of them.
170 Self::DogConfigChanged { dog } => return Cow::Owned(format!("config.dog.{dog}")),
171 };
172 Cow::Borrowed(fixed)
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use crate::protocol::request::{ExitInfo, ProcessInfo};
180 use crate::status::ProcStatus;
181
182 #[test]
183 fn bus_event_wire_snapshots() {
184 let mut events = vec![
185 BusEvent::Process {
186 event: ProcessEventKind::Exit,
187 info: ProcessInfo {
188 id: 3,
189 name: "web".to_string(),
190 status: ProcStatus::WaitingRestart,
191 pid: None,
192 restarts: 2,
193 uptime_ms: 500,
194 fold: None,
195 depends_on: Vec::new(),
196 out_file: Some("/home/ada/.shep/logs/web-0-out.log".to_string()),
197 err_file: Some("/home/ada/.shep/logs/web-0-err.log".to_string()),
198 // A bus event is built from the actor's own snapshot,
199 // which never carries a resource reading.
200 cpu_percent: None,
201 memory_bytes: None,
202 dog: None,
203 lambs: None,
204 // `handle_exited` sets `last_exit` before deciding what
205 // to do with the exit, so this `Exit` row carries the
206 // outcome it announces.
207 last_exit: Some(ExitInfo {
208 code: Some(1),
209 signal: None,
210 }),
211 // A non-ASCII marker on purpose: this snapshot is what
212 // pins the encoding a subscriber reads, and a smit is
213 // the one field on this row a third party writes.
214 smit: Some("\u{25b2} main@a1b2c3".to_string()),
215 instance: None,
216 handshook: None,
217 dog_stale: None,
218 pending: None,
219 overridden: None,
220 max_memory: None,
221 },
222 manually: false,
223 at_ms: 1_700_000_000_000,
224 },
225 BusEvent::LogOut {
226 id: 3,
227 line: "listening on :8080".to_string(),
228 },
229 BusEvent::Dropped { count: 17 },
230 // The row above pins `instance`'s absent shape; every lifecycle
231 // row below reuses it via `sample`. This is the only place the
232 // present shape (a live slot on a scaled app) is on the wire.
233 BusEvent::Process {
234 event: ProcessEventKind::Online,
235 info: ProcessInfo::builder(4, "web", ProcStatus::Online)
236 .pid(Some(5150))
237 .instance(Some(2))
238 .build(),
239 manually: false,
240 at_ms: 1_700_000_000_000,
241 },
242 ];
243
244 // One identical `info` reused below, so these rows differ only by
245 // their `event` tag: a variant rename changes the wire string
246 // silently otherwise.
247 let sample = ProcessInfo::builder(3, "web", ProcStatus::WaitingRestart)
248 .restarts(2)
249 .uptime_ms(500)
250 .out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
251 .err_file(Some("/home/ada/.shep/logs/web-0-err.log".to_string()))
252 // Reused below for `Stop` and `Delete` too: both still carry the
253 // exit that produced them.
254 .last_exit(Some(ExitInfo {
255 code: Some(1),
256 signal: None,
257 }))
258 .build();
259
260 let lifecycle = [
261 ProcessEventKind::Start,
262 ProcessEventKind::Online,
263 ProcessEventKind::Restart,
264 ProcessEventKind::Stop,
265 ProcessEventKind::Delete,
266 ProcessEventKind::Errored,
267 ]
268 .map(|event| BusEvent::Process {
269 event,
270 info: sample.clone(),
271 manually: false,
272 at_ms: 1_700_000_000_000,
273 });
274
275 events.extend(lifecycle);
276
277 // The adjacent-tagged shape nests the message's own `kind` inside
278 // `data`, next to `id`: easy to get wrong by hand.
279 events.extend([
280 BusEvent::Channel {
281 id: 3,
282 message: ChildMessage::Ready,
283 },
284 BusEvent::Channel {
285 id: 3,
286 message: ChildMessage::Metric {
287 name: "rps".to_string(),
288 value: 42.0,
289 },
290 },
291 BusEvent::Channel {
292 id: 3,
293 message: ChildMessage::ActionReply {
294 action: "gc".to_string(),
295 body: "freed 12MB".to_string(),
296 id: Some(7),
297 },
298 },
299 ]);
300
301 // Last, so every row above keeps its index. The one topic that is
302 // not a fixed string, and the one frame a dog subscribes to on its
303 // own name: an operator's `dogs.toml` edit reaches a running dog
304 // through this shape or through nothing.
305 events.push(BusEvent::DogConfigChanged {
306 dog: "bark".to_string(),
307 });
308
309 insta::assert_json_snapshot!("bus_event_wire_v6", events);
310 }
311
312 #[test]
313 fn topics_follow_the_dotted_grammar() {
314 // spec §6: process.* / log.out / log.err / daemon.*
315 let e = BusEvent::LogOut {
316 id: 1,
317 line: String::new(),
318 };
319 assert_eq!(e.topic(), "log.out");
320 assert_eq!(BusEvent::DaemonShutdown.topic(), "daemon.shutdown");
321 }
322
323 /// The three kinds a reload reports itself with, pinned as topic
324 /// strings and wire strings: a reload's reply is an acceptance, so
325 /// these frames are the only place a client learns how it went.
326 #[test]
327 fn a_reload_reports_itself_under_three_topics() {
328 for (kind, topic, wire) in [
329 (ProcessEventKind::Reload, "process.reload", "\"reload\""),
330 (
331 ProcessEventKind::Reloaded,
332 "process.reloaded",
333 "\"reloaded\"",
334 ),
335 (
336 ProcessEventKind::ReloadAbandoned,
337 "process.reload_abandoned",
338 "\"reload_abandoned\"",
339 ),
340 ] {
341 let event = BusEvent::Process {
342 event: kind,
343 info: ProcessInfo {
344 id: 3,
345 name: "web".to_string(),
346 status: ProcStatus::Stopping,
347 pid: Some(4242),
348 restarts: 0,
349 uptime_ms: 0,
350 fold: None,
351 depends_on: Vec::new(),
352 out_file: None,
353 err_file: None,
354 cpu_percent: None,
355 memory_bytes: None,
356 dog: None,
357 lambs: None,
358 last_exit: None,
359 smit: None,
360 instance: None,
361 handshook: None,
362 dog_stale: None,
363 pending: None,
364 overridden: None,
365 max_memory: None,
366 },
367 manually: true,
368 at_ms: 0,
369 };
370 assert_eq!(event.topic(), topic, "{kind:?}");
371 assert_eq!(serde_json::to_string(&kind).unwrap(), wire, "{kind:?}");
372 }
373 }
374
375 #[test]
376 fn v1_bus_event_fixture_still_deserializes() {
377 // Adjacent-tagged shape pinned as a byte fixture.
378 let fixture = r#"{"event":"log_out","data":{"id":3,"line":"ready"}}"#;
379 let ev: BusEvent = serde_json::from_str(fixture).unwrap();
380 assert!(matches!(ev, BusEvent::LogOut { id: 3, .. }));
381 }
382
383 /// The exact topic strings are the contract, not just the `channel.*`
384 /// prefix.
385 #[test]
386 fn every_shepherd_channel_message_has_its_own_topic() {
387 for (message, topic) in [
388 (ChildMessage::Ready, "channel.ready"),
389 (
390 ChildMessage::Metric {
391 name: "rps".to_string(),
392 value: 42.0,
393 },
394 "channel.metric",
395 ),
396 (
397 ChildMessage::ActionReply {
398 action: "gc".to_string(),
399 body: "ok".to_string(),
400 id: Some(7),
401 },
402 "channel.action_reply",
403 ),
404 ] {
405 let event = BusEvent::Channel {
406 id: 3,
407 message: message.clone(),
408 };
409 assert_eq!(event.topic(), topic, "{message:?}");
410 }
411 }
412
413 /// `channel.*` is the only pattern anyone subscribes with; a topic that
414 /// drifts out from under it becomes unreachable.
415 #[test]
416 fn the_channel_glob_reaches_all_three_topics() {
417 for message in [
418 ChildMessage::Ready,
419 ChildMessage::Metric {
420 name: "rps".to_string(),
421 value: 1.0,
422 },
423 ChildMessage::ActionReply {
424 action: "gc".to_string(),
425 body: String::new(),
426 id: None,
427 },
428 ] {
429 let topic = BusEvent::Channel { id: 1, message }.topic();
430 assert!(
431 topic.starts_with("channel."),
432 "`{topic}` is not under the channel.* glob"
433 );
434 }
435 }
436
437 /// The message carries verbatim: nothing on this wire is a credential.
438 #[test]
439 fn a_channel_event_carries_the_message_verbatim() {
440 let event = BusEvent::Channel {
441 id: 3,
442 message: ChildMessage::ActionReply {
443 action: "gc".to_string(),
444 body: "freed 12MB".to_string(),
445 id: Some(7),
446 },
447 };
448 let json = serde_json::to_string(&event).unwrap();
449 assert!(json.contains("freed 12MB"), "{json}");
450 assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
451 }
452
453 /// The topic is the whole of what a dog subscribes with; a name that
454 /// misses it leaves the dog listening to nothing.
455 #[test]
456 fn a_dog_config_event_names_the_dog_in_its_topic() {
457 for dog in ["bark", "metrics", "otel-shipper"] {
458 let event = BusEvent::DogConfigChanged {
459 dog: dog.to_string(),
460 };
461 assert_eq!(event.topic(), format!("config.dog.{dog}"));
462 }
463 }
464
465 /// A value here would put another dog's webhook credential in front of
466 /// every subscriber on `config.*`.
467 #[test]
468 fn a_dog_config_event_carries_the_name_and_nothing_else() {
469 let event = BusEvent::DogConfigChanged {
470 dog: "bark".to_string(),
471 };
472 let json = serde_json::to_string(&event).unwrap();
473 assert_eq!(
474 json,
475 r#"{"event":"dog_config_changed","data":{"dog":"bark"}}"#
476 );
477 assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
478 }
479}