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