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