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 cpu_ms: None,
257 dog: None,
258 lambs: None,
259 // `handle_exited` sets `last_exit` before deciding what
260 // to do with the exit, so this `Exit` row carries the
261 // outcome it announces.
262 last_exit: Some(ExitInfo {
263 code: Some(1),
264 signal: None,
265 }),
266 // A non-ASCII marker on purpose: this snapshot is what
267 // pins the encoding a subscriber reads, and a smit is
268 // the one field on this row a third party writes.
269 smit: Some("\u{25b2} main@a1b2c3".to_string()),
270 instance: None,
271 handshook: None,
272 dog_stale: None,
273 pending: None,
274 overridden: None,
275 max_memory: None,
276 level_rules: Vec::new(),
277 reload_deadline_ms: None,
278 },
279 manually: false,
280 at_ms: 1_700_000_000_000,
281 },
282 BusEvent::LogOut {
283 id: 3,
284 line: "listening on :8080".to_string(),
285 },
286 BusEvent::Dropped { count: 17 },
287 // The row above pins `instance`'s absent shape; every lifecycle
288 // row below reuses it via `sample`. This is the only place the
289 // present shape (a live slot on a scaled app) is on the wire.
290 BusEvent::Process {
291 event: ProcessEventKind::Online,
292 info: ProcessInfo::builder(4, "web", ProcStatus::Online)
293 .pid(Some(5150))
294 .instance(Some(2))
295 .build(),
296 manually: false,
297 at_ms: 1_700_000_000_000,
298 },
299 ];
300
301 // One identical `info` reused below, so these rows differ only by
302 // their `event` tag: a variant rename changes the wire string
303 // silently otherwise.
304 let sample = ProcessInfo::builder(3, "web", ProcStatus::WaitingRestart)
305 .restarts(2)
306 .uptime_ms(500)
307 .out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
308 .err_file(Some("/home/ada/.shep/logs/web-0-err.log".to_string()))
309 // Reused below for `Stop` and `Delete` too: both still carry the
310 // exit that produced them.
311 .last_exit(Some(ExitInfo {
312 code: Some(1),
313 signal: None,
314 }))
315 .build();
316
317 let lifecycle = [
318 ProcessEventKind::Start,
319 ProcessEventKind::Online,
320 ProcessEventKind::Restart,
321 ProcessEventKind::Stop,
322 ProcessEventKind::Delete,
323 ProcessEventKind::Errored,
324 ]
325 .map(|event| BusEvent::Process {
326 event,
327 info: sample.clone(),
328 manually: false,
329 at_ms: 1_700_000_000_000,
330 });
331
332 events.extend(lifecycle);
333
334 // The adjacent-tagged shape nests the message's own `kind` inside
335 // `data`, next to `id`: easy to get wrong by hand.
336 events.extend([
337 BusEvent::Channel {
338 id: 3,
339 message: ChildMessage::Ready,
340 },
341 BusEvent::Channel {
342 id: 3,
343 message: ChildMessage::Metric {
344 name: "rps".to_string(),
345 value: 42.0,
346 },
347 },
348 BusEvent::Channel {
349 id: 3,
350 message: ChildMessage::ActionReply {
351 action: "gc".to_string(),
352 body: "freed 12MB".to_string(),
353 id: Some(7),
354 },
355 },
356 ]);
357
358 // Last, so every row above keeps its index. The one topic that is
359 // not a fixed string, and the one frame a dog subscribes to on its
360 // own name: an operator's `dogs.toml` edit reaches a running dog
361 // through this shape or through nothing.
362 events.push(BusEvent::DogConfigChanged {
363 dog: "bark".to_string(),
364 });
365
366 insta::assert_json_snapshot!("bus_event_wire_v9", events);
367 }
368
369 #[test]
370 fn topics_follow_the_dotted_grammar() {
371 // spec §6: process.* / log.out / log.err / daemon.*
372 let e = BusEvent::LogOut {
373 id: 1,
374 line: String::new(),
375 };
376 assert_eq!(e.topic(), "log.out");
377 assert_eq!(BusEvent::DaemonShutdown.topic(), "daemon.shutdown");
378 }
379
380 /// The three kinds a reload reports itself with, pinned as topic
381 /// strings and wire strings: a reload's reply is an acceptance, so
382 /// these frames are the only place a client learns how it went.
383 #[test]
384 fn a_reload_reports_itself_under_three_topics() {
385 for (kind, topic, wire) in [
386 (ProcessEventKind::Reload, "process.reload", "\"reload\""),
387 (
388 ProcessEventKind::Reloaded,
389 "process.reloaded",
390 "\"reloaded\"",
391 ),
392 (
393 ProcessEventKind::ReloadAbandoned,
394 "process.reload_abandoned",
395 "\"reload_abandoned\"",
396 ),
397 ] {
398 let event = BusEvent::Process {
399 event: kind,
400 // Scaffolding: `topic()` reads only `event`, and nothing
401 // here is asserted. The builder says so; the exhaustive
402 // literal in `bus_event_wire_snapshots` says the opposite
403 // about its own row, deliberately.
404 info: ProcessInfo::builder(3, "web", ProcStatus::Stopping)
405 .pid(Some(4242))
406 .build(),
407 manually: true,
408 at_ms: 0,
409 };
410 assert_eq!(event.topic(), topic, "{kind:?}");
411 assert_eq!(serde_json::to_string(&kind).unwrap(), wire, "{kind:?}");
412 }
413 }
414
415 #[test]
416 fn v1_bus_event_fixture_still_deserializes() {
417 // Adjacent-tagged shape pinned as a byte fixture.
418 let fixture = r#"{"event":"log_out","data":{"id":3,"line":"ready"}}"#;
419 let ev: BusEvent = serde_json::from_str(fixture).unwrap();
420 assert!(matches!(ev, BusEvent::LogOut { id: 3, .. }));
421 }
422
423 /// The exact topic strings are the contract, not just the `channel.*`
424 /// prefix.
425 #[test]
426 fn every_shepherd_channel_message_has_its_own_topic() {
427 for (message, topic) in [
428 (ChildMessage::Ready, "channel.ready"),
429 (
430 ChildMessage::Metric {
431 name: "rps".to_string(),
432 value: 42.0,
433 },
434 "channel.metric",
435 ),
436 (
437 ChildMessage::ActionReply {
438 action: "gc".to_string(),
439 body: "ok".to_string(),
440 id: Some(7),
441 },
442 "channel.action_reply",
443 ),
444 ] {
445 let event = BusEvent::Channel {
446 id: 3,
447 message: message.clone(),
448 };
449 assert_eq!(event.topic(), topic, "{message:?}");
450 }
451 }
452
453 /// `channel.*` is the only pattern anyone subscribes with; a topic that
454 /// drifts out from under it becomes unreachable.
455 #[test]
456 fn the_channel_glob_reaches_all_three_topics() {
457 for message in [
458 ChildMessage::Ready,
459 ChildMessage::Metric {
460 name: "rps".to_string(),
461 value: 1.0,
462 },
463 ChildMessage::ActionReply {
464 action: "gc".to_string(),
465 body: String::new(),
466 id: None,
467 },
468 ] {
469 let topic = BusEvent::Channel { id: 1, message }.topic();
470 assert!(
471 topic.starts_with("channel."),
472 "`{topic}` is not under the channel.* glob"
473 );
474 }
475 }
476
477 /// The message carries verbatim: nothing on this wire is a credential.
478 #[test]
479 fn a_channel_event_carries_the_message_verbatim() {
480 let event = BusEvent::Channel {
481 id: 3,
482 message: ChildMessage::ActionReply {
483 action: "gc".to_string(),
484 body: "freed 12MB".to_string(),
485 id: Some(7),
486 },
487 };
488 let json = serde_json::to_string(&event).unwrap();
489 assert!(json.contains("freed 12MB"), "{json}");
490 assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
491 }
492
493 /// The topic is the whole of what a dog subscribes with; a name that
494 /// misses it leaves the dog listening to nothing.
495 #[test]
496 fn a_dog_config_event_names_the_dog_in_its_topic() {
497 for dog in ["bark", "metrics", "otel-shipper"] {
498 let event = BusEvent::DogConfigChanged {
499 dog: dog.to_string(),
500 };
501 assert_eq!(event.topic(), format!("config.dog.{dog}"));
502 }
503 }
504
505 /// A value here would put another dog's webhook credential in front of
506 /// every subscriber on `config.*`.
507 #[test]
508 fn a_dog_config_event_carries_the_name_and_nothing_else() {
509 let event = BusEvent::DogConfigChanged {
510 dog: "bark".to_string(),
511 };
512 let json = serde_json::to_string(&event).unwrap();
513 assert_eq!(
514 json,
515 r#"{"event":"dog_config_changed","data":{"dog":"bark"}}"#
516 );
517 assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
518 }
519}