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