shep_core/protocol/request/process.rs
1//! One sheep's row on the wire, its builder, and everything the row embeds.
2
3use serde::{Deserialize, Serialize};
4
5use crate::config::LevelRule;
6use crate::status::ProcStatus;
7
8// Named by intra-doc links and by nothing rustc compiles, so the
9// import is behind `cfg(doc)` rather than flagged unused.
10#[cfg(doc)]
11use super::{Response, SheepDrift, Smit};
12#[cfg(doc)]
13use crate::config::AppConfig;
14
15/// Where a dog came from: this binary, or one an operator adopted.
16///
17/// Carried on [`ProcessInfo::dog`], so a listing distinguishes the two
18/// populations without a second request.
19// wire format: changing existing variants is a breaking change
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(tag = "kind", rename_all = "snake_case")]
22#[non_exhaustive]
23pub enum DogSource {
24 /// An argv branch of the shep binary itself (`shep dog <name>`).
25 BuiltIn,
26 /// A binary an operator adopted, run at the daemon's own trust level.
27 Adopted {
28 /// The binary's path, exactly as the operator gave it to `adopt`.
29 path: String,
30 },
31}
32
33/// One process the OS reports as a descendant of a sheep.
34///
35/// Not the set of processes that die with the sheep: this is a parent-pid
36/// walk, where the stop ladder acts on the process group. A lamb that forks
37/// and exits leaves children re-parented to init, out of this list and still
38/// in the group; a `setsid()` grandchild stays in the list and leaves the
39/// group.
40///
41/// `name` is the executable's name (`node`, `sh`), never argv, which carries
42/// credentials and would ride into `shep describe --format json`. Build one
43/// with [`Self::new`].
44// wire format: changing this is a breaking change
45#[non_exhaustive]
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct Lamb {
48 /// The lamb's own pid.
49 pub pid: u32,
50 /// The executable's name, as the OS reports it. Never its command line.
51 pub name: String,
52}
53
54impl Lamb {
55 /// One lamb.
56 #[must_use]
57 pub fn new(pid: u32, name: impl Into<String>) -> Self {
58 Self {
59 pid,
60 name: name.into(),
61 }
62 }
63}
64
65/// Why a sheep's process most recently stopped existing under this daemon.
66///
67/// Behind [`ProcessInfo::last_exit`]'s own `Option`, so `None` there means
68/// never exited. Ordinarily exactly one of `code`/`signal` is `Some`,
69/// mirroring the OS's `WIFEXITED`/`WIFSIGNALED` split; both `None` together
70/// is legal and means this daemon recorded an exit it could not
71/// characterize.
72// wire format: changing this is a breaking change
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74pub struct ExitInfo {
75 /// The process's own exit code, set on a normal exit (`WIFEXITED`).
76 pub code: Option<i32>,
77 /// The raw unix signal number that ended the process, set when it did
78 /// not exit on its own (`WIFSIGNALED`). An operator's own `shep stop`
79 /// counts: the process still stopped by a signal.
80 ///
81 /// Platform-specific, and never rendered as a name here; that is an
82 /// OS-aware layer's job.
83 pub signal: Option<i32>,
84}
85
86/// Snapshot of one sheep for listings and events
87///
88/// Construct one with [`ProcessInfo::builder`]: `#[non_exhaustive]` forbids
89/// a struct literal outside this crate, though not inside it. The fields
90/// stay `pub`.
91// wire format: changing this is a breaking change. No `Eq`: `cpu_percent` is
92// an `f32`. Paths travel as `String`, since serde's `PathBuf` refuses a
93// non-UTF-8 path and would blank a whole `Reply`. Every added field is an
94// `Option` or a defaulted collection, so a peer built before it sends no key
95// and the empty reading means unknown.
96#[non_exhaustive]
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98pub struct ProcessInfo {
99 /// Stable numeric id
100 pub id: u32,
101 /// Sheep name
102 pub name: String,
103 /// Lifecycle status
104 pub status: ProcStatus,
105 /// OS pid while running
106 pub pid: Option<u32>,
107 /// Restart count since registration
108 pub restarts: u32,
109 /// Milliseconds since last successful start
110 pub uptime_ms: u64,
111 /// Fold membership
112 pub fold: Option<String>,
113 /// Names this sheep waits for at a staged start, from its
114 /// `depends_on`. Empty both when the sheep declares none and when the
115 /// peer daemon predates the field.
116 #[serde(default)]
117 pub depends_on: Vec<String>,
118 /// Resolved stdout log path: the app's explicit
119 /// [`AppConfig::out_file`] when it set one, else the daemon-derived
120 /// default. `None` only when the peer daemon predates this field.
121 pub out_file: Option<String>,
122 /// Resolved stderr log path, resolved exactly as [`Self::out_file`]
123 pub err_file: Option<String>,
124 /// Tree CPU as a percentage of one core, over the window since the
125 /// daemon's last periodic sample. `None` when the sheep is not running,
126 /// when it has been up for less than one sampling window, or when the
127 /// peer daemon predates the field; all three render as unknown, never as
128 /// zero. A value over 100 is a tree using more than one core.
129 pub cpu_percent: Option<f32>,
130 /// Tree resident set size in bytes, current as of the reply. `None`
131 /// under the same three conditions as [`Self::cpu_percent`], minus the
132 /// window one: memory needs no baseline.
133 pub memory_bytes: Option<u64>,
134 /// The tree's cumulative CPU-milliseconds, or `None` when the shepherd
135 /// is not sampling this sheep.
136 ///
137 /// Present in one case [`Self::cpu_percent`] is not: a sheep spawned
138 /// since the last periodic tick has a counter already, but no baseline
139 /// to measure it against, so this is `Some` while the percent is still
140 /// `None`.
141 ///
142 /// The counter rather than a rate, so a client polling faster than the
143 /// shepherd's own sampling interval can difference two readings and get
144 /// the mean over its own interval. [`Self::cpu_percent`] cannot serve
145 /// that: it is measured against a baseline the shepherd rewrites on its
146 /// own schedule, so consecutive readings share one and each is a running
147 /// mean over a window that grows and then resets.
148 pub cpu_ms: Option<u64>,
149 /// Set when this entry is a dog, naming where the dog came from;
150 /// `None` for a sheep.
151 ///
152 /// Two cases, not [`Self::cpu_percent`]'s three: "not a dog" is the true
153 /// answer whether the peer predates the field or the entry is a sheep.
154 pub dog: Option<DogSource>,
155 /// The processes the OS reports as descendants of this sheep, or `None`
156 /// when this reply did not walk for them.
157 ///
158 /// `None` covers two cases: this reply is not a `Describe` (only
159 /// `Describe` walks), or the peer daemon predates the field.
160 /// `Some(vec![])` is the third: walked, and this sheep has no children.
161 ///
162 /// Read [`Lamb`]'s own doc before rendering this: the list is not the set
163 /// of processes a stop kills, and any output built from it has to say so.
164 pub lambs: Option<Vec<Lamb>>,
165 /// How this sheep's process most recently stopped existing under this
166 /// daemon. `None` while it has never exited under this daemon, and when
167 /// the peer daemon predates the field.
168 ///
169 /// Sticky across a respawn: it answers why the sheep last stopped, not
170 /// whether it is stopped now, and updates only on the next exit.
171 pub last_exit: Option<ExitInfo>,
172 /// The marker a dog has asked to have painted beside this sheep, or
173 /// `None` when no dog has painted one, which also covers a peer daemon
174 /// that predates the field.
175 ///
176 /// A `String` rather than a [`Smit`]: this is a report, and the
177 /// validation that makes it safe to print happened at the daemon's
178 /// ingress. Every instance of a name shows the same marker, since smits
179 /// are keyed by sheep name.
180 pub smit: Option<String>,
181 /// Which instance slot of its app this sheep occupies, counting from 0.
182 ///
183 /// `None` when the peer daemon predates the field. Not a bare `u32`
184 /// defaulted to 0: an app stocked to four instances would report four
185 /// rows all claiming slot 0.
186 pub instance: Option<u32>,
187 /// Whether this dog has completed a handshake with the shepherd that is
188 /// reporting it, and not been refused since; `None` for a sheep.
189 ///
190 /// `None` on [`Self::dog`]'s two-case terms: a sheep has no connection
191 /// to the shepherd, so "no handshake fact to report" is the true answer
192 /// for a sheep and for a peer that predates the field alike.
193 ///
194 /// `Some(false)` is the one that matters: a dog on a protocol this
195 /// shepherd refuses is alive, which is all [`Self::status`] reports, and
196 /// not doing its job. A fact and not a verdict, though: a dog spawned a
197 /// moment ago has not handshaken yet and is healthy.
198 pub handshook: Option<bool>,
199 /// Whether the reporting shepherd has given up on this dog: restarted it
200 /// once for never answering, watched that not help, and stopped
201 /// restarting it. `None` for a sheep, on [`Self::handshook`]'s terms.
202 ///
203 /// Not derivable from [`Self::handshook`]. `Some(false)` there covers
204 /// both a dog spawned three seconds ago that has not dialled back and one
205 /// this shepherd has permanently stopped restarting; the first needs
206 /// nothing done and the second is an incident.
207 ///
208 /// A fact and not a verdict: it says the shepherd stopped, never why.
209 /// The why is in that dog's own log (`shep bleats <dog>`).
210 pub dog_stale: Option<bool>,
211 /// The [`AppConfig`] field names this sheep's spec differs from a load's
212 /// parked config for, in field-name order. `None` when nothing is parked,
213 /// and when the peer daemon predates the field.
214 ///
215 /// Names only, never values, as [`SheepDrift::fields`] carries them: a
216 /// differing `env` reports `"env"` and stops there. `shep reload`
217 /// promotes a parked config.
218 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub pending: Option<Vec<String>>,
220 /// The [`AppConfig`] field names an operator has set on this sheep that
221 /// its current Flockfile does not declare, in field-name order. `None`
222 /// when there is nothing to report, and when the peer daemon predates
223 /// the field.
224 ///
225 /// Names only, never values, for [`Self::pending`]'s reason:
226 /// [`crate::overrides::AppOverrides::fields`] can hold an `env` value.
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub overridden: Option<Vec<String>>,
229 /// The sheep's `max_memory` ceiling in bytes, when it has one.
230 ///
231 /// Additive, like [`Self::instance`] and [`Self::handshook`] before it, so
232 /// neither `PROTOCOL_VERSION` nor `SCHEMA_VERSION` moves: an older payload
233 /// decodes with it absent and an older client ignores it. Lookout's
234 /// `MEM/CEIL` gauge is the only reader; `None` draws an all-tail bar
235 /// rather than guessing a denominator.
236 pub max_memory: Option<u64>,
237 /// How this sheep's own log lines announce their level, from its
238 /// [`AppConfig::level_rules`](crate::config::AppConfig::level_rules).
239 ///
240 /// Empty both when the sheep declares none and when the peer daemon
241 /// predates the field, which read the same way: a client classifying
242 /// this sheep's lines falls back to its own reading of them. The key is
243 /// absent from the payload entirely when the list is empty.
244 // On the listing rather than behind a fetch of its own: a client reads
245 // this on every line it draws, and a listing it already polls cannot go
246 // stale between polls.
247 #[serde(default, skip_serializing_if = "Vec::is_empty")]
248 pub level_rules: Vec<LevelRule>,
249 /// How long the shepherd gives ONE swap of this instance before it
250 /// gives up on it: this instance's own `listen_timeout` plus its
251 /// `graceful_timeout` plus the shepherd's slack, in milliseconds.
252 ///
253 /// `Some` only on the rows of a reload's acceptance
254 /// ([`Response::Reloading`]), and only for an instance that reload will
255 /// try to replace. `None` everywhere else, which covers a listing with
256 /// no reload in flight, an instance a reload is skipping, and a peer
257 /// daemon that predates the field.
258 ///
259 /// Per instance, not per app: a client that inferred it from its own
260 /// copy of the Flockfile would be reading a file the shepherd may have
261 /// moved past. Additive, like [`Self::max_memory`] before it, so
262 /// neither `PROTOCOL_VERSION` nor `SCHEMA_VERSION` moves.
263 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub reload_deadline_ms: Option<u64>,
265}
266
267/// Orders one flock listing the way every operator-facing surface presents
268/// one: `(name, instance, id)`.
269///
270/// Name first: an id is assigned at registration and a `delete all` plus a
271/// fresh start renumbers the flock, where a name survives. A name is not a
272/// total order on its own, so the id breaks the tie and stays an addressing
273/// key (`shep stop 11`).
274///
275/// A listing whose rows all carry `None` for the slot collapses to
276/// `(name, id)`, since `None` sorts before every `Some`.
277pub fn sort_flock(listing: &mut [ProcessInfo]) {
278 listing.sort_unstable_by(|a, b| {
279 (a.name.as_str(), a.instance, a.id).cmp(&(b.name.as_str(), b.instance, b.id))
280 });
281}
282
283impl ProcessInfo {
284 /// Starts a builder for one sheep's row.
285 ///
286 /// The three arguments are the fields no row can omit and no reader can
287 /// default.
288 ///
289 /// No `#[must_use]`: [`ProcessInfoBuilder`] carries one, which clippy's
290 /// `double_must_use` lint treats as covering this return too.
291 pub fn builder(id: u32, name: impl Into<String>, status: ProcStatus) -> ProcessInfoBuilder {
292 ProcessInfoBuilder {
293 info: Self {
294 id,
295 name: name.into(),
296 status,
297 pid: None,
298 restarts: 0,
299 uptime_ms: 0,
300 fold: None,
301 depends_on: Vec::new(),
302 out_file: None,
303 err_file: None,
304 cpu_percent: None,
305 memory_bytes: None,
306 cpu_ms: None,
307 dog: None,
308 lambs: None,
309 last_exit: None,
310 smit: None,
311 instance: None,
312 handshook: None,
313 dog_stale: None,
314 pending: None,
315 overridden: None,
316 max_memory: None,
317 level_rules: Vec::new(),
318 reload_deadline_ms: None,
319 },
320 }
321 }
322}
323
324/// Builds a [`ProcessInfo`], which is `#[non_exhaustive]` and so cannot be
325/// written as a struct literal outside this crate.
326///
327/// Every setter takes the field's own type, `Option` included, so a caller
328/// already holding `Option<u32>` writes `.pid(entry.pid())` rather than an
329/// `if let` ladder. A setter is skipped, not passed `None`, when a row has
330/// nothing to say about that field; the skipped defaults are the ones a
331/// not-yet-running sheep has.
332#[derive(Debug, Clone)]
333#[must_use = "a builder that is never `build`-ed produces no ProcessInfo"]
334pub struct ProcessInfoBuilder {
335 info: ProcessInfo,
336}
337
338impl ProcessInfoBuilder {
339 /// Sets the OS pid; `None` while the sheep is not running.
340 pub fn pid(mut self, pid: Option<u32>) -> Self {
341 self.info.pid = pid;
342 self
343 }
344
345 /// Sets the restart count since registration.
346 pub fn restarts(mut self, restarts: u32) -> Self {
347 self.info.restarts = restarts;
348 self
349 }
350
351 /// Sets milliseconds since the last successful start.
352 pub fn uptime_ms(mut self, uptime_ms: u64) -> Self {
353 self.info.uptime_ms = uptime_ms;
354 self
355 }
356
357 /// Sets fold membership.
358 pub fn fold(mut self, fold: Option<String>) -> Self {
359 self.info.fold = fold;
360 self
361 }
362
363 /// Sets the names this sheep waits for at a staged start.
364 pub fn depends_on(mut self, depends_on: Vec<String>) -> Self {
365 self.info.depends_on = depends_on;
366 self
367 }
368
369 /// Sets the resolved stdout log path.
370 pub fn out_file(mut self, out_file: Option<String>) -> Self {
371 self.info.out_file = out_file;
372 self
373 }
374
375 /// Sets the resolved stderr log path.
376 pub fn err_file(mut self, err_file: Option<String>) -> Self {
377 self.info.err_file = err_file;
378 self
379 }
380
381 /// Sets tree CPU as a percentage of one core.
382 pub fn cpu_percent(mut self, cpu_percent: Option<f32>) -> Self {
383 self.info.cpu_percent = cpu_percent;
384 self
385 }
386
387 /// Sets tree resident set size in bytes.
388 pub fn memory_bytes(mut self, memory_bytes: Option<u64>) -> Self {
389 self.info.memory_bytes = memory_bytes;
390 self
391 }
392
393 /// Sets the tree's cumulative CPU-milliseconds; `None` when unsampled.
394 pub fn cpu_ms(mut self, cpu_ms: Option<u64>) -> Self {
395 self.info.cpu_ms = cpu_ms;
396 self
397 }
398
399 /// Marks this row a dog and names where the dog came from.
400 pub fn dog(mut self, dog: Option<DogSource>) -> Self {
401 self.info.dog = dog;
402 self
403 }
404
405 /// Sets the sheep's lamb list; `None` when this reply did not walk for one.
406 pub fn lambs(mut self, lambs: Option<Vec<Lamb>>) -> Self {
407 self.info.lambs = lambs;
408 self
409 }
410
411 /// Sets how this sheep's process most recently stopped; `None` while it
412 /// has never exited under this daemon.
413 pub fn last_exit(mut self, last_exit: Option<ExitInfo>) -> Self {
414 self.info.last_exit = last_exit;
415 self
416 }
417
418 /// Sets the marker a dog has painted on this sheep; `None` when none has.
419 pub fn smit(mut self, smit: Option<String>) -> Self {
420 self.info.smit = smit;
421 self
422 }
423
424 /// Sets the instance slot; `None` when the peer daemon predates the field.
425 pub fn instance(mut self, instance: Option<u32>) -> Self {
426 self.info.instance = instance;
427 self
428 }
429
430 /// Sets whether this dog has handshaken with the shepherd; `None` for a
431 /// sheep, which has no handshake to report.
432 pub fn handshook(mut self, handshook: Option<bool>) -> Self {
433 self.info.handshook = handshook;
434 self
435 }
436
437 /// Sets whether the shepherd has given up restarting this dog; `None`
438 /// for a sheep, which is never given up on.
439 pub fn dog_stale(mut self, dog_stale: Option<bool>) -> Self {
440 self.info.dog_stale = dog_stale;
441 self
442 }
443
444 /// Sets the field names a load has parked for this sheep's next spawn;
445 /// `None` when nothing is parked.
446 pub fn pending(mut self, pending: Option<Vec<String>>) -> Self {
447 self.info.pending = pending;
448 self
449 }
450
451 /// Sets the field names an operator has overridden on this sheep;
452 /// `None` when there is nothing to report.
453 pub fn overridden(mut self, overridden: Option<Vec<String>>) -> Self {
454 self.info.overridden = overridden;
455 self
456 }
457
458 /// Sets the sheep's `max_memory` ceiling in bytes; `None` when it has no
459 /// ceiling configured.
460 pub fn max_memory(mut self, max_memory: Option<u64>) -> Self {
461 self.info.max_memory = max_memory;
462 self
463 }
464
465 /// Sets the sheep's declared level rules; empty when it declares none.
466 pub fn level_rules(mut self, level_rules: Vec<LevelRule>) -> Self {
467 self.info.level_rules = level_rules;
468 self
469 }
470
471 /// Sets one swap's own deadline in milliseconds; `None` on every row
472 /// but a reload's, and on a row that reload will not replace.
473 pub fn reload_deadline_ms(mut self, reload_deadline_ms: Option<u64>) -> Self {
474 self.info.reload_deadline_ms = reload_deadline_ms;
475 self
476 }
477
478 /// Finishes the row.
479 #[must_use]
480 pub fn build(self) -> ProcessInfo {
481 self.info
482 }
483}
484
485#[cfg(test)]
486pub(super) fn sample_info() -> ProcessInfo {
487 ProcessInfo {
488 id: 3,
489 name: "web".to_string(),
490 status: ProcStatus::Online,
491 pid: Some(4242),
492 restarts: 1,
493 uptime_ms: 60_000,
494 fold: Some("backend".to_string()),
495 // Left empty: this fixture feeds `reply_wire_snapshots` and
496 // `bus_event_wire_snapshots`, so a non-empty value moves pinned
497 // bytes.
498 depends_on: Vec::new(),
499 out_file: Some("/home/ada/.shep/logs/web-0-out.log".to_string()),
500 err_file: Some("/home/ada/.shep/logs/web-0-err.log".to_string()),
501 // 12.5: an insta JSON snapshot is stable across platforms only
502 // for a float the binary representation holds exactly.
503 cpu_percent: Some(12.5),
504 memory_bytes: Some(48 * 1024 * 1024),
505 cpu_ms: None,
506 dog: None,
507 lambs: None,
508 last_exit: Some(ExitInfo {
509 code: Some(1),
510 signal: None,
511 }),
512 smit: None,
513 instance: None,
514 handshook: None,
515 dog_stale: None,
516 // Left at the builder's default: this fixture feeds
517 // `reply_wire_snapshots` and `bus_event_wire_snapshots`, so a
518 // `Some(..)` moves pinned bytes.
519 pending: None,
520 overridden: None,
521 max_memory: Some(512 * 1024 * 1024),
522 // Populated where its list-shaped neighbours above are not:
523 // nothing else on the wire pins a `LevelRule`'s field names or a
524 // `LineLevel` spelling, and an empty list would prove neither.
525 level_rules: vec![crate::config::LevelRule {
526 pattern: r"\[ERROR\]".to_string(),
527 level: crate::config::LineLevel::Error,
528 }],
529 // `None` here so the key is absent from the pinned bytes; the
530 // serialized shape of a real one is pinned on its own, by
531 // `a_reload_deadline_is_absent_from_the_wire_until_a_reload_sets_one`.
532 reload_deadline_ms: None,
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539
540 #[test]
541 fn a_builder_with_nothing_set_is_a_sheep_that_has_not_run() {
542 let info = ProcessInfo::builder(3, "web", ProcStatus::Stopped).build();
543
544 assert_eq!(info.id, 3);
545 assert_eq!(info.name, "web");
546 assert_eq!(info.status, ProcStatus::Stopped);
547 assert_eq!(info.pid, None);
548 assert_eq!(info.restarts, 0);
549 assert_eq!(info.uptime_ms, 0);
550 assert_eq!(info.fold, None);
551 assert_eq!(info.out_file, None);
552 assert_eq!(info.err_file, None);
553 assert_eq!(info.cpu_percent, None);
554 assert_eq!(info.memory_bytes, None);
555 assert_eq!(info.cpu_ms, None);
556 assert_eq!(info.dog, None);
557 assert_eq!(info.lambs, None);
558 assert_eq!(info.last_exit, None);
559 }
560
561 /// The raw counter rides the wire beside the percent. A client polling
562 /// faster than the shepherd's own sampling interval differences two of
563 /// these; `cpu_percent` cannot serve it, because consecutive readings
564 /// share a baseline.
565 #[test]
566 fn a_process_info_carries_the_cpu_counter() {
567 let info = ProcessInfo::builder(3, "web", ProcStatus::Online)
568 .cpu_ms(Some(1_234))
569 .build();
570 assert_eq!(info.cpu_ms, Some(1_234));
571 }
572
573 /// Absent by default, like every other sampled field: a lifecycle verb's
574 /// answer carries no reading.
575 #[test]
576 fn a_process_info_without_a_reading_has_no_counter() {
577 let info = ProcessInfo::builder(3, "web", ProcStatus::Online).build();
578 assert_eq!(info.cpu_ms, None);
579 }
580
581 /// Every field is given a value distinct from every other field's
582 /// default, so a copy-pasted setter body shows up as a mismatch.
583 #[test]
584 fn every_setter_writes_its_own_field_and_no_other() {
585 let built = ProcessInfo::builder(3, "web", ProcStatus::Online)
586 .pid(Some(4242))
587 .restarts(1)
588 .uptime_ms(60_000)
589 .fold(Some("backend".to_string()))
590 .out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
591 .err_file(Some("/home/ada/.shep/logs/web-0-err.log".to_string()))
592 .cpu_percent(Some(12.5))
593 .memory_bytes(Some(48 * 1024 * 1024))
594 .dog(None)
595 .last_exit(Some(ExitInfo {
596 code: Some(1),
597 signal: None,
598 }))
599 .max_memory(Some(512 * 1024 * 1024))
600 .level_rules(vec![crate::config::LevelRule {
601 pattern: r"\[ERROR\]".to_string(),
602 level: crate::config::LineLevel::Error,
603 }])
604 .build();
605
606 // `sample_info()` is a struct literal on purpose: it is the one
607 // place that names every field by hand, so this comparison fails the
608 // day the struct grows a field the builder cannot set.
609 assert_eq!(built, sample_info());
610
611 // `sample_info()`'s `dog` is `None`, the builder's default too, so an
612 // empty `dog` setter body would pass the comparison above. It cannot
613 // be changed: it feeds pinned snapshots.
614 assert_eq!(
615 ProcessInfo::builder(1, "metrics", ProcStatus::Online)
616 .dog(Some(DogSource::BuiltIn))
617 .build()
618 .dog,
619 Some(DogSource::BuiltIn),
620 "an empty `dog` setter body is invisible to the comparison above"
621 );
622
623 // `lambs`, on `dog`'s terms above.
624 assert_eq!(
625 ProcessInfo::builder(1, "web", ProcStatus::Online)
626 .lambs(Some(vec![Lamb::new(4243, "node")]))
627 .build()
628 .lambs,
629 Some(vec![Lamb::new(4243, "node")]),
630 "an empty `lambs` setter body is invisible to the comparison above"
631 );
632
633 // `smit`, on the same terms, and the field a third party writes: an
634 // empty setter body drops every dog's mark.
635 assert_eq!(
636 ProcessInfo::builder(1, "web", ProcStatus::Online)
637 .smit(Some("\u{25b2} main@a1b2c3".to_string()))
638 .build()
639 .smit
640 .as_deref(),
641 Some("\u{25b2} main@a1b2c3"),
642 "an empty `smit` setter body is invisible to the comparison above"
643 );
644
645 // `handshook`, on the same terms.
646 assert_eq!(
647 ProcessInfo::builder(1, "web", ProcStatus::Online)
648 .handshook(Some(false))
649 .build()
650 .handshook,
651 Some(false),
652 "an empty `handshook` setter body is invisible to the comparison above"
653 );
654
655 // `dog_stale`, paired with `handshook`: both default to `None`.
656 assert_eq!(
657 ProcessInfo::builder(1, "web", ProcStatus::Online)
658 .dog_stale(Some(true))
659 .build()
660 .dog_stale,
661 Some(true),
662 "an empty `dog_stale` setter body is invisible to the comparison above"
663 );
664
665 // `pending`, on the same terms.
666 assert_eq!(
667 ProcessInfo::builder(1, "web", ProcStatus::Online)
668 .pending(Some(vec!["env".to_string()]))
669 .build()
670 .pending,
671 Some(vec!["env".to_string()]),
672 "an empty `pending` setter body is invisible to the comparison above"
673 );
674
675 // `overridden`, on the same terms.
676 assert_eq!(
677 ProcessInfo::builder(1, "web", ProcStatus::Online)
678 .overridden(Some(vec!["cwd".to_string()]))
679 .build()
680 .overridden,
681 Some(vec!["cwd".to_string()]),
682 "an empty `overridden` setter body is invisible to the comparison above"
683 );
684 }
685
686 #[test]
687 fn lambs_distinguishes_not_walked_from_walked_and_empty() {
688 let not_walked = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
689 assert_eq!(not_walked.lambs, None);
690
691 let walked_empty = ProcessInfo::builder(1, "web", ProcStatus::Online)
692 .lambs(Some(Vec::new()))
693 .build();
694 assert_eq!(walked_empty.lambs, Some(Vec::new()));
695 }
696
697 #[test]
698 fn a_process_info_without_a_lambs_key_still_deserializes() {
699 let fixture = r#"{
700 "id": 3, "name": "web", "status": "online", "pid": 4242,
701 "restarts": 0, "uptime_ms": 100, "fold": null,
702 "out_file": null, "err_file": null,
703 "cpu_percent": null, "memory_bytes": null, "dog": null
704 }"#;
705 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
706 assert_eq!(info.lambs, None);
707 }
708
709 /// argv holds credentials (`--password=`, `?token=`) and
710 /// `shep describe --format json` is output people paste into issues.
711 #[test]
712 fn a_lamb_is_a_pid_and_an_executable_name() {
713 let lamb = Lamb::new(4243, "node");
714 let json = serde_json::to_string(&lamb).unwrap();
715 assert_eq!(json, r#"{"pid":4243,"name":"node"}"#);
716 assert_eq!(serde_json::from_str::<Lamb>(&json).unwrap(), lamb);
717 }
718
719 #[test]
720 fn a_dog_source_serializes_snake_case_under_its_kind() {
721 assert_eq!(
722 serde_json::to_string(&DogSource::BuiltIn).unwrap(),
723 r#"{"kind":"built_in"}"#
724 );
725 let adopted = DogSource::Adopted {
726 path: "/usr/local/bin/shep-otel".to_string(),
727 };
728 let wire = r#"{"kind":"adopted","path":"/usr/local/bin/shep-otel"}"#;
729 assert_eq!(serde_json::to_string(&adopted).unwrap(), wire);
730 assert_eq!(serde_json::from_str::<DogSource>(wire).unwrap(), adopted);
731 }
732
733 #[test]
734 fn v1_process_info_without_a_dog_marker_still_deserializes() {
735 let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend","out_file":"/l/o.log","err_file":"/l/e.log","cpu_percent":12.5,"memory_bytes":50331648}"#;
736 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
737 assert_eq!(info.dog, None);
738 }
739
740 /// No field here carries `#[serde(default)]`: serde's derive resolves a
741 /// missing key to `None` for a field whose type is syntactically
742 /// `Option<...>`.
743 #[test]
744 fn a_process_info_without_a_last_exit_key_still_deserializes() {
745 let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend","out_file":"/l/o.log","err_file":"/l/e.log","cpu_percent":12.5,"memory_bytes":50331648,"dog":null,"lambs":null}"#;
746 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
747 assert_eq!(info.last_exit, None);
748 }
749
750 #[test]
751 fn a_process_info_without_a_smit_key_still_deserializes() {
752 let fixture = r#"{"id":1,"name":"web","status":"online","pid":42,"restarts":0,"uptime_ms":10,"fold":null,"out_file":null,"err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":null,"lambs":null,"last_exit":null}"#;
753 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
754 assert_eq!(info.smit, None);
755 }
756
757 /// The fixture is a dog's row, where `None` means "render this as it
758 /// rendered before the field existed", never "never handshaken".
759 #[test]
760 fn a_process_info_without_a_handshook_key_still_deserializes() {
761 let fixture = r#"{"id":1,"name":"metrics","status":"online","pid":42,"restarts":0,"uptime_ms":10,"fold":null,"out_file":null,"err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":{"kind":"built_in"},"lambs":null,"last_exit":null,"smit":null,"instance":0}"#;
762 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
763 assert_eq!(info.handshook, None);
764 assert_eq!(info.dog, Some(DogSource::BuiltIn));
765 }
766
767 /// The fixture carries `handshook: false`, the case that matters: `None`
768 /// is "no verdict to report", never "it has not given up".
769 #[test]
770 fn a_process_info_without_a_dog_stale_key_still_deserializes() {
771 let fixture = r#"{"id":1,"name":"metrics","status":"online","pid":42,"restarts":0,"uptime_ms":10,"fold":null,"out_file":null,"err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":{"kind":"built_in"},"lambs":null,"last_exit":null,"smit":null,"instance":0,"handshook":false}"#;
772 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
773 assert_eq!(info.dog_stale, None);
774 assert_eq!(info.handshook, Some(false));
775 }
776
777 #[test]
778 fn a_process_info_carries_its_memory_ceiling_and_defaults_to_none() {
779 let plain = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
780 assert_eq!(
781 plain.max_memory, None,
782 "a sheep with no ceiling reports none"
783 );
784
785 let capped = ProcessInfo::builder(2, "hungry", ProcStatus::Online)
786 .max_memory(Some(52 * 1024 * 1024))
787 .build();
788 assert_eq!(capped.max_memory, Some(54_525_952));
789 }
790
791 /// Absent rather than `null` when there is no reload, so a listing's
792 /// payload is the shape it always was and `SCHEMA_VERSION` stays put.
793 /// Present as a plain number when a reload sets one, which is the only
794 /// place a client reads it.
795 #[test]
796 fn a_reload_deadline_is_absent_from_the_wire_until_a_reload_sets_one() {
797 let quiet = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
798 let json = serde_json::to_string(&quiet).expect("serialize");
799 assert!(
800 !json.contains("reload_deadline_ms"),
801 "a row with no reload carries no key at all: {json}"
802 );
803
804 let reloading = ProcessInfo::builder(1, "web", ProcStatus::Online)
805 .reload_deadline_ms(Some(16_000))
806 .build();
807 let json = serde_json::to_string(&reloading).expect("serialize");
808 assert!(
809 json.contains(r#""reload_deadline_ms":16000"#),
810 "a reload's own row carries the number: {json}"
811 );
812 let back: ProcessInfo = serde_json::from_str(&json).expect("round trip");
813 assert_eq!(back.reload_deadline_ms, Some(16_000));
814 }
815
816 #[test]
817 fn an_older_daemons_process_info_still_decodes() {
818 // Every additive field, asserted off one payload. A payload written
819 // before any of them existed has to decode with each absent rather
820 // than fail the whole envelope, and one fixture cannot drift from
821 // another.
822 let older = r#"{"id":1,"name":"web","status":"online","restarts":0,"uptime_ms":0}"#;
823 let info: ProcessInfo = serde_json::from_str(older).expect("an older payload decodes");
824 assert_eq!(info.max_memory, None);
825 assert_eq!(info.reload_deadline_ms, None);
826 }
827
828 #[test]
829 fn v1_process_info_without_stats_still_deserializes() {
830 let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend","out_file":"/l/o.log","err_file":"/l/e.log"}"#;
831 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
832 assert_eq!(info.cpu_percent, None);
833 assert_eq!(info.memory_bytes, None);
834 }
835
836 #[test]
837 fn v1_process_info_without_log_paths_still_deserializes() {
838 // Committed byte fixture from before `out_file`/`err_file` existed.
839 let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend"}"#;
840 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
841 assert_eq!(info.id, 3);
842 assert_eq!(info.out_file, None);
843 assert_eq!(info.err_file, None);
844 }
845
846 #[test]
847 fn an_old_client_still_decodes_a_new_process_info() {
848 // `ProcessInfo` carries no `deny_unknown_fields`, unlike the config
849 // types in `crate::config`, so extra keys are ignored.
850 #[derive(Deserialize)]
851 struct V1ProcessInfo {
852 id: u32,
853 fold: Option<String>,
854 }
855
856 let current = serde_json::to_string(&sample_info()).unwrap();
857 let old: V1ProcessInfo = serde_json::from_str(¤t).unwrap();
858 assert_eq!(old.id, 3);
859 assert_eq!(old.fold.as_deref(), Some("backend"));
860 }
861
862 /// The fixture cannot agree under either candidate order: by id it is
863 /// `web/1, api/2, web/0`, by name `api, web, web`. The two `web` rows
864 /// are the tiebreak half, seeded out of order.
865 #[test]
866 fn a_listing_sorts_by_name_then_by_id() {
867 let mut listing = vec![
868 ProcessInfo::builder(1, "web", ProcStatus::Online).build(),
869 ProcessInfo::builder(2, "api", ProcStatus::Online).build(),
870 ProcessInfo::builder(0, "web", ProcStatus::Online).build(),
871 ];
872 sort_flock(&mut listing);
873
874 let seen: Vec<(&str, u32)> = listing
875 .iter()
876 .map(|info| (info.name.as_str(), info.id))
877 .collect();
878 assert_eq!(
879 seen,
880 vec![("api", 2), ("web", 0), ("web", 1)],
881 "name first, then id inside a name"
882 );
883 }
884
885 #[test]
886 fn an_instance_slot_survives_a_round_trip_and_defaults_to_absent() {
887 let with = ProcessInfo::builder(1, "web", ProcStatus::Online)
888 .instance(Some(2))
889 .build();
890 assert_eq!(with.instance, Some(2));
891
892 let without = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
893 assert_eq!(
894 without.instance, None,
895 "a row nobody set a slot on says so, rather than claiming slot 0"
896 );
897 }
898
899 #[test]
900 fn a_reply_from_a_daemon_without_the_field_deserializes_as_absent() {
901 let json = r#"{"id":1,"name":"web","status":"online","pid":null,
902 "restarts":0,"uptime_ms":0,"fold":null,"out_file":null,
903 "err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":null,
904 "lambs":null,"last_exit":null,"smit":null}"#;
905 let info: ProcessInfo = serde_json::from_str(json).expect("older reply still parses");
906 assert_eq!(info.instance, None);
907 }
908
909 #[test]
910 fn sort_flock_orders_by_slot_before_id() {
911 // A reload gave slot 0 a fresh, higher id. Slot order must still win.
912 let mut listing = vec![
913 ProcessInfo::builder(9, "web", ProcStatus::Online)
914 .instance(Some(0))
915 .build(),
916 ProcessInfo::builder(2, "web", ProcStatus::Online)
917 .instance(Some(1))
918 .build(),
919 ];
920 sort_flock(&mut listing);
921 assert_eq!(
922 listing.iter().map(|i| i.id).collect::<Vec<_>>(),
923 vec![9, 2],
924 "slot 0 leads even though its id is higher"
925 );
926 }
927
928 #[test]
929 fn sort_flock_falls_back_to_id_when_no_row_carries_a_slot() {
930 let mut listing = vec![
931 ProcessInfo::builder(5, "web", ProcStatus::Online).build(),
932 ProcessInfo::builder(3, "web", ProcStatus::Online).build(),
933 ];
934 sort_flock(&mut listing);
935 assert_eq!(
936 listing.iter().map(|i| i.id).collect::<Vec<_>>(),
937 vec![3, 5],
938 "an older daemon's listing sorts exactly as it does today"
939 );
940 }
941}