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 /// A dog's `[app.dogs.<name>]` table changed on one sheep. Published
142 /// under `config.sheep.<dog>`, so a dog subscribes to its own name and
143 /// hears nobody else's, the way [`Self::DogConfigChanged`] does for
144 /// `dogs.toml`.
145 ///
146 /// Fires once per dog and sheep for a change that adds, removes or
147 /// edits that sheep's table for `dog`: a name gaining its first table,
148 /// the last instance of such a name being deleted, or a load,
149 /// `Request::SetSheepDogSettings` or `--reset` changing one. Never for
150 /// a handover, since that changes no answer.
151 ///
152 /// Carries only the sheep's name and the dog's: a table can hold a
153 /// credential, the same reason [`Self::DogConfigChanged`] carries only
154 /// the dog's own name. A dog that wants the values re-asks with
155 /// [`Request::DogSheepSettings`](crate::protocol::Request::DogSheepSettings),
156 /// which answers with that dog's tables.
157 DogSheepSettingsChanged {
158 /// The dog whose table changed.
159 dog: String,
160 /// The sheep whose table changed.
161 sheep: String,
162 },
163}
164
165impl BusEvent {
166 /// The dotted subscription topic for this event (spec §6 grammar)
167 ///
168 /// A [`Cow`] rather than a `&'static str`, because two topics are not
169 /// fixed: [`Self::DogConfigChanged`] and [`Self::DogSheepSettingsChanged`]
170 /// name their dog in the topic itself, which is what lets a dog
171 /// subscribe to its own config and hear nobody else's. Every other
172 /// variant is still a borrowed literal and allocates nothing.
173 #[must_use]
174 pub fn topic(&self) -> Cow<'static, str> {
175 let fixed = match self {
176 Self::Process { event, .. } => match event {
177 ProcessEventKind::Start => "process.start",
178 ProcessEventKind::Online => "process.online",
179 ProcessEventKind::Exit => "process.exit",
180 ProcessEventKind::Restart => "process.restart",
181 ProcessEventKind::Reload => "process.reload",
182 ProcessEventKind::Reloaded => "process.reloaded",
183 ProcessEventKind::ReloadAbandoned => "process.reload_abandoned",
184 ProcessEventKind::Stop => "process.stop",
185 ProcessEventKind::Delete => "process.delete",
186 ProcessEventKind::Errored => "process.errored",
187 // Decode-only, never emitted by this build. Given its own
188 // topic rather than acted on: a subscriber sees it pass
189 // through like any other kind it does not specifically
190 // filter for, instead of the daemon treating it as a real
191 // lifecycle transition.
192 ProcessEventKind::Unrecognized => "process.unrecognized",
193 },
194 Self::LogOut { .. } => "log.out",
195 Self::LogErr { .. } => "log.err",
196 // Total match over `ChildMessage`: a new kind on fd 3 fails
197 // to compile here until its topic is decided.
198 Self::Channel { message, .. } => match message {
199 ChildMessage::Ready => "channel.ready",
200 ChildMessage::Metric { .. } => "channel.metric",
201 ChildMessage::ActionReply { .. } => "channel.action_reply",
202 ChildMessage::LambLabel { .. } => "channel.lamb_label",
203 },
204 Self::Dropped { .. } => "daemon.dropped",
205 Self::DaemonShutdown => "daemon.shutdown",
206 // Built rather than named. `config.dog.` is the prefix a
207 // subscriber globs on; the dog's own name is the last segment,
208 // so `config.dog.bark` reaches one dog and `config.*` reaches
209 // all of them.
210 Self::DogConfigChanged { dog } => return Cow::Owned(format!("config.dog.{dog}")),
211 // Named by the dog, like `DogConfigChanged` above: the sheep is
212 // the payload, not the topic, so a dog subscribes once for
213 // every sheep carrying its table.
214 Self::DogSheepSettingsChanged { dog, .. } => {
215 return Cow::Owned(format!("config.sheep.{dog}"));
216 }
217 };
218 Cow::Borrowed(fixed)
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use crate::protocol::LambLabel;
226 use crate::protocol::request::{ExitInfo, ProcessInfo};
227 use crate::status::ProcStatus;
228
229 /// The comment above this enum said a new variant is not free because
230 /// there is no fallback. This is the fallback.
231 #[test]
232 fn an_unknown_process_event_decodes_as_unrecognized() {
233 assert_eq!(
234 serde_json::from_str::<ProcessEventKind>(r#""invented_next_year""#).unwrap(),
235 ProcessEventKind::Unrecognized
236 );
237 }
238
239 #[test]
240 fn every_known_process_event_still_round_trips() {
241 for kind in [
242 ProcessEventKind::Start,
243 ProcessEventKind::Online,
244 ProcessEventKind::Exit,
245 ProcessEventKind::Restart,
246 ProcessEventKind::Reload,
247 ProcessEventKind::Reloaded,
248 ProcessEventKind::ReloadAbandoned,
249 ProcessEventKind::Stop,
250 ProcessEventKind::Delete,
251 ProcessEventKind::Errored,
252 ] {
253 let json = serde_json::to_string(&kind).unwrap();
254 assert_eq!(
255 serde_json::from_str::<ProcessEventKind>(&json).unwrap(),
256 kind
257 );
258 }
259 }
260
261 #[test]
262 fn a_non_string_process_event_is_still_an_error() {
263 assert!(serde_json::from_str::<ProcessEventKind>("42").is_err());
264 }
265
266 #[test]
267 fn bus_event_wire_snapshots() {
268 let mut events = vec![
269 BusEvent::Process {
270 event: ProcessEventKind::Exit,
271 info: ProcessInfo {
272 id: 3,
273 name: "web".to_string(),
274 status: ProcStatus::WaitingRestart,
275 pid: None,
276 restarts: 2,
277 uptime_ms: 500,
278 fold: None,
279 depends_on: Vec::new(),
280 out_file: Some("/home/ada/.shep/logs/web-0-out.log".to_string()),
281 err_file: Some("/home/ada/.shep/logs/web-0-err.log".to_string()),
282 // A bus event is built from the actor's own snapshot,
283 // which never carries a resource reading.
284 cpu_percent: None,
285 memory_bytes: None,
286 cpu_ms: None,
287 dog: None,
288 lambs: None,
289 // `handle_exited` sets `last_exit` before deciding what
290 // to do with the exit, so this `Exit` row carries the
291 // outcome it announces.
292 last_exit: Some(ExitInfo {
293 code: Some(1),
294 signal: None,
295 }),
296 // A non-ASCII marker on purpose: this snapshot is what
297 // pins the encoding a subscriber reads, and a smit is
298 // the one field on this row a third party writes.
299 smit: Some("\u{25b2} main@a1b2c3".to_string()),
300 instance: None,
301 handshook: None,
302 dog_stale: None,
303 pending: None,
304 overridden: None,
305 max_memory: None,
306 level_rules: Vec::new(),
307 reload_deadline_ms: None,
308 },
309 manually: false,
310 at_ms: 1_700_000_000_000,
311 },
312 BusEvent::LogOut {
313 id: 3,
314 line: "listening on :8080".to_string(),
315 },
316 BusEvent::Dropped { count: 17 },
317 // The row above pins `instance`'s absent shape; every lifecycle
318 // row below reuses it via `sample`. This is the only place the
319 // present shape (a live slot on a scaled app) is on the wire.
320 BusEvent::Process {
321 event: ProcessEventKind::Online,
322 info: ProcessInfo::builder(4, "web", ProcStatus::Online)
323 .pid(Some(5150))
324 .instance(Some(2))
325 .build(),
326 manually: false,
327 at_ms: 1_700_000_000_000,
328 },
329 ];
330
331 // One identical `info` reused below, so these rows differ only by
332 // their `event` tag: a variant rename changes the wire string
333 // silently otherwise.
334 let sample = ProcessInfo::builder(3, "web", ProcStatus::WaitingRestart)
335 .restarts(2)
336 .uptime_ms(500)
337 .out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
338 .err_file(Some("/home/ada/.shep/logs/web-0-err.log".to_string()))
339 // Reused below for `Stop` and `Delete` too: both still carry the
340 // exit that produced them.
341 .last_exit(Some(ExitInfo {
342 code: Some(1),
343 signal: None,
344 }))
345 .build();
346
347 let lifecycle = [
348 ProcessEventKind::Start,
349 ProcessEventKind::Online,
350 ProcessEventKind::Restart,
351 ProcessEventKind::Stop,
352 ProcessEventKind::Delete,
353 ProcessEventKind::Errored,
354 ]
355 .map(|event| BusEvent::Process {
356 event,
357 info: sample.clone(),
358 manually: false,
359 at_ms: 1_700_000_000_000,
360 });
361
362 events.extend(lifecycle);
363
364 // The adjacent-tagged shape nests the message's own `kind` inside
365 // `data`, next to `id`: easy to get wrong by hand.
366 events.extend([
367 BusEvent::Channel {
368 id: 3,
369 message: ChildMessage::Ready,
370 },
371 BusEvent::Channel {
372 id: 3,
373 message: ChildMessage::Metric {
374 name: "rps".to_string(),
375 value: 42.0,
376 },
377 },
378 BusEvent::Channel {
379 id: 3,
380 message: ChildMessage::ActionReply {
381 action: "gc".to_string(),
382 body: "freed 12MB".to_string(),
383 id: Some(7),
384 },
385 },
386 ]);
387
388 // Last, so every row above keeps its index. The one topic that is
389 // not a fixed string, and the one frame a dog subscribes to on its
390 // own name: an operator's `dogs.toml` edit reaches a running dog
391 // through this shape or through nothing.
392 events.push(BusEvent::DogConfigChanged {
393 dog: "bark".to_string(),
394 });
395 // The second event whose topic is built rather than named, and the
396 // one that carries a sheep's name beside the dog's.
397 events.push(BusEvent::DogSheepSettingsChanged {
398 dog: "jobs".to_string(),
399 sheep: "web".to_string(),
400 });
401
402 insta::assert_json_snapshot!("bus_event_wire_v11", events);
403 }
404
405 #[test]
406 fn topics_follow_the_dotted_grammar() {
407 // spec §6: process.* / log.out / log.err / daemon.*
408 let e = BusEvent::LogOut {
409 id: 1,
410 line: String::new(),
411 };
412 assert_eq!(e.topic(), "log.out");
413 assert_eq!(BusEvent::DaemonShutdown.topic(), "daemon.shutdown");
414 }
415
416 /// The three kinds a reload reports itself with, pinned as topic
417 /// strings and wire strings: a reload's reply is an acceptance, so
418 /// these frames are the only place a client learns how it went.
419 #[test]
420 fn a_reload_reports_itself_under_three_topics() {
421 for (kind, topic, wire) in [
422 (ProcessEventKind::Reload, "process.reload", "\"reload\""),
423 (
424 ProcessEventKind::Reloaded,
425 "process.reloaded",
426 "\"reloaded\"",
427 ),
428 (
429 ProcessEventKind::ReloadAbandoned,
430 "process.reload_abandoned",
431 "\"reload_abandoned\"",
432 ),
433 ] {
434 let event = BusEvent::Process {
435 event: kind,
436 // Scaffolding: `topic()` reads only `event`, and nothing
437 // here is asserted. The builder says so; the exhaustive
438 // literal in `bus_event_wire_snapshots` says the opposite
439 // about its own row, deliberately.
440 info: ProcessInfo::builder(3, "web", ProcStatus::Stopping)
441 .pid(Some(4242))
442 .build(),
443 manually: true,
444 at_ms: 0,
445 };
446 assert_eq!(event.topic(), topic, "{kind:?}");
447 assert_eq!(serde_json::to_string(&kind).unwrap(), wire, "{kind:?}");
448 }
449 }
450
451 #[test]
452 fn v1_bus_event_fixture_still_deserializes() {
453 // Adjacent-tagged shape pinned as a byte fixture.
454 let fixture = r#"{"event":"log_out","data":{"id":3,"line":"ready"}}"#;
455 let ev: BusEvent = serde_json::from_str(fixture).unwrap();
456 assert!(matches!(ev, BusEvent::LogOut { id: 3, .. }));
457 }
458
459 /// The exact topic strings are the contract, not just the `channel.*`
460 /// prefix.
461 #[test]
462 fn every_shepherd_channel_message_has_its_own_topic() {
463 for (message, topic) in [
464 (ChildMessage::Ready, "channel.ready"),
465 (
466 ChildMessage::Metric {
467 name: "rps".to_string(),
468 value: 42.0,
469 },
470 "channel.metric",
471 ),
472 (
473 ChildMessage::ActionReply {
474 action: "gc".to_string(),
475 body: "ok".to_string(),
476 id: Some(7),
477 },
478 "channel.action_reply",
479 ),
480 (
481 ChildMessage::LambLabel {
482 pid: 4312,
483 label: LambLabel::new("worker 1").unwrap(),
484 },
485 "channel.lamb_label",
486 ),
487 ] {
488 let event = BusEvent::Channel {
489 id: 3,
490 message: message.clone(),
491 };
492 assert_eq!(event.topic(), topic, "{message:?}");
493 }
494 }
495
496 /// `channel.*` is the only pattern anyone subscribes with; a topic that
497 /// drifts out from under it becomes unreachable.
498 #[test]
499 fn the_channel_glob_reaches_every_channel_topic() {
500 for message in [
501 ChildMessage::Ready,
502 ChildMessage::Metric {
503 name: "rps".to_string(),
504 value: 1.0,
505 },
506 ChildMessage::ActionReply {
507 action: "gc".to_string(),
508 body: String::new(),
509 id: None,
510 },
511 ChildMessage::LambLabel {
512 pid: 1,
513 label: LambLabel::new("").unwrap(),
514 },
515 ] {
516 let topic = BusEvent::Channel { id: 1, message }.topic();
517 assert!(
518 topic.starts_with("channel."),
519 "`{topic}` is not under the channel.* glob"
520 );
521 }
522 }
523
524 /// The message carries verbatim: nothing on this wire is a credential.
525 #[test]
526 fn a_channel_event_carries_the_message_verbatim() {
527 let event = BusEvent::Channel {
528 id: 3,
529 message: ChildMessage::ActionReply {
530 action: "gc".to_string(),
531 body: "freed 12MB".to_string(),
532 id: Some(7),
533 },
534 };
535 let json = serde_json::to_string(&event).unwrap();
536 assert!(json.contains("freed 12MB"), "{json}");
537 assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
538 }
539
540 /// The topic is the whole of what a dog subscribes with; a name that
541 /// misses it leaves the dog listening to nothing.
542 #[test]
543 fn a_dog_config_event_names_the_dog_in_its_topic() {
544 for dog in ["bark", "metrics", "otel-shipper"] {
545 let event = BusEvent::DogConfigChanged {
546 dog: dog.to_string(),
547 };
548 assert_eq!(event.topic(), format!("config.dog.{dog}"));
549 }
550 }
551
552 /// A value here would put another dog's webhook credential in front of
553 /// every subscriber on `config.*`.
554 #[test]
555 fn a_dog_config_event_carries_the_name_and_nothing_else() {
556 let event = BusEvent::DogConfigChanged {
557 dog: "bark".to_string(),
558 };
559 let json = serde_json::to_string(&event).unwrap();
560 assert_eq!(
561 json,
562 r#"{"event":"dog_config_changed","data":{"dog":"bark"}}"#
563 );
564 assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
565 }
566
567 /// The topic names the dog, not the sheep, so one dog's subscription
568 /// reaches every sheep carrying its table.
569 #[test]
570 fn a_dog_sheep_settings_event_names_the_dog_in_its_topic() {
571 for dog in ["jobs", "metrics"] {
572 let event = BusEvent::DogSheepSettingsChanged {
573 dog: dog.to_string(),
574 sheep: "web".to_string(),
575 };
576 assert_eq!(event.topic(), format!("config.sheep.{dog}"));
577 }
578 }
579
580 /// `config.*` still reaches this event, beside `config.dog.*`: a
581 /// dashboard watching every dog's config must not lose this topic to a
582 /// glob that only matched the older shape.
583 #[test]
584 fn a_dog_sheep_settings_topic_is_still_under_the_config_glob() {
585 let event = BusEvent::DogSheepSettingsChanged {
586 dog: "jobs".to_string(),
587 sheep: "web".to_string(),
588 };
589 assert!(event.topic().starts_with("config."));
590 }
591
592 /// The sheep rides in the payload, not the topic: a dog subscribes once
593 /// on its own name and hears about every sheep carrying its table.
594 #[test]
595 fn a_dog_sheep_settings_event_carries_the_sheep_and_the_dog() {
596 let event = BusEvent::DogSheepSettingsChanged {
597 dog: "jobs".to_string(),
598 sheep: "web".to_string(),
599 };
600 let json = serde_json::to_string(&event).unwrap();
601 assert_eq!(
602 json,
603 r#"{"event":"dog_sheep_settings_changed","data":{"dog":"jobs","sheep":"web"}}"#
604 );
605 assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
606 }
607}