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 /// `Some(true)` when the sheep's app is disabled: stopped, and refused a
265 /// spawn until `shep enable`. `None` otherwise, which also covers a peer
266 /// daemon that predates the field.
267 ///
268 /// Beside [`Self::status`] rather than a [`ProcStatus`] variant: that enum
269 /// is closed, so an older peer could not decode a new one. A disabled row
270 /// keeps `status: "stopped"`. Never `Some(false)`, so the key is absent
271 /// from every row that is not disabled.
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub disabled: Option<bool>,
274 /// Whether this sheep's own log lines carry the shepherd's stamp, from
275 /// its [`AppConfig::log_timestamps`]. `Some(false)` is the only value a
276 /// daemon sets. `None` for a dog, which always stamps, and for a peer
277 /// daemon that predates the field: both read as stamped, which is what
278 /// every daemon before it wrote. Additive, like [`Self::disabled`].
279 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub log_timestamps: Option<bool>,
281}
282
283/// Orders one flock listing the way every operator-facing surface presents
284/// one: `(name, instance, id)`.
285///
286/// Name first: an id is assigned at registration and a `delete all` plus a
287/// fresh start renumbers the flock, where a name survives. A name is not a
288/// total order on its own, so the id breaks the tie and stays an addressing
289/// key (`shep stop 11`).
290///
291/// A listing whose rows all carry `None` for the slot collapses to
292/// `(name, id)`, since `None` sorts before every `Some`.
293pub fn sort_flock(listing: &mut [ProcessInfo]) {
294 listing.sort_unstable_by(|a, b| {
295 (a.name.as_str(), a.instance, a.id).cmp(&(b.name.as_str(), b.instance, b.id))
296 });
297}
298
299impl ProcessInfo {
300 /// Starts a builder for one sheep's row.
301 ///
302 /// The three arguments are the fields no row can omit and no reader can
303 /// default.
304 ///
305 /// No `#[must_use]`: [`ProcessInfoBuilder`] carries one, which clippy's
306 /// `double_must_use` lint treats as covering this return too.
307 pub fn builder(id: u32, name: impl Into<String>, status: ProcStatus) -> ProcessInfoBuilder {
308 ProcessInfoBuilder {
309 info: Self {
310 id,
311 name: name.into(),
312 status,
313 pid: None,
314 restarts: 0,
315 uptime_ms: 0,
316 fold: None,
317 depends_on: Vec::new(),
318 out_file: None,
319 err_file: None,
320 cpu_percent: None,
321 memory_bytes: None,
322 cpu_ms: None,
323 dog: None,
324 lambs: None,
325 last_exit: None,
326 smit: None,
327 instance: None,
328 handshook: None,
329 dog_stale: None,
330 pending: None,
331 overridden: None,
332 max_memory: None,
333 level_rules: Vec::new(),
334 reload_deadline_ms: None,
335 disabled: None,
336 log_timestamps: None,
337 },
338 }
339 }
340}
341
342/// Builds a [`ProcessInfo`], which is `#[non_exhaustive]` and so cannot be
343/// written as a struct literal outside this crate.
344///
345/// Every setter takes the field's own type, `Option` included, so a caller
346/// already holding `Option<u32>` writes `.pid(entry.pid())` rather than an
347/// `if let` ladder. A setter is skipped, not passed `None`, when a row has
348/// nothing to say about that field; the skipped defaults are the ones a
349/// not-yet-running sheep has.
350#[derive(Debug, Clone)]
351#[must_use = "a builder that is never `build`-ed produces no ProcessInfo"]
352pub struct ProcessInfoBuilder {
353 info: ProcessInfo,
354}
355
356impl ProcessInfoBuilder {
357 /// Sets the OS pid; `None` while the sheep is not running.
358 pub fn pid(mut self, pid: Option<u32>) -> Self {
359 self.info.pid = pid;
360 self
361 }
362
363 /// Sets the restart count since registration.
364 pub fn restarts(mut self, restarts: u32) -> Self {
365 self.info.restarts = restarts;
366 self
367 }
368
369 /// Sets milliseconds since the last successful start.
370 pub fn uptime_ms(mut self, uptime_ms: u64) -> Self {
371 self.info.uptime_ms = uptime_ms;
372 self
373 }
374
375 /// Sets fold membership.
376 pub fn fold(mut self, fold: Option<String>) -> Self {
377 self.info.fold = fold;
378 self
379 }
380
381 /// Sets the names this sheep waits for at a staged start.
382 pub fn depends_on(mut self, depends_on: Vec<String>) -> Self {
383 self.info.depends_on = depends_on;
384 self
385 }
386
387 /// Sets the resolved stdout log path.
388 pub fn out_file(mut self, out_file: Option<String>) -> Self {
389 self.info.out_file = out_file;
390 self
391 }
392
393 /// Sets the resolved stderr log path.
394 pub fn err_file(mut self, err_file: Option<String>) -> Self {
395 self.info.err_file = err_file;
396 self
397 }
398
399 /// Sets tree CPU as a percentage of one core.
400 pub fn cpu_percent(mut self, cpu_percent: Option<f32>) -> Self {
401 self.info.cpu_percent = cpu_percent;
402 self
403 }
404
405 /// Sets tree resident set size in bytes.
406 pub fn memory_bytes(mut self, memory_bytes: Option<u64>) -> Self {
407 self.info.memory_bytes = memory_bytes;
408 self
409 }
410
411 /// Sets the tree's cumulative CPU-milliseconds; `None` when unsampled.
412 pub fn cpu_ms(mut self, cpu_ms: Option<u64>) -> Self {
413 self.info.cpu_ms = cpu_ms;
414 self
415 }
416
417 /// Marks this row a dog and names where the dog came from.
418 pub fn dog(mut self, dog: Option<DogSource>) -> Self {
419 self.info.dog = dog;
420 self
421 }
422
423 /// Sets the sheep's lamb list; `None` when this reply did not walk for one.
424 pub fn lambs(mut self, lambs: Option<Vec<Lamb>>) -> Self {
425 self.info.lambs = lambs;
426 self
427 }
428
429 /// Sets how this sheep's process most recently stopped; `None` while it
430 /// has never exited under this daemon.
431 pub fn last_exit(mut self, last_exit: Option<ExitInfo>) -> Self {
432 self.info.last_exit = last_exit;
433 self
434 }
435
436 /// Sets the marker a dog has painted on this sheep; `None` when none has.
437 pub fn smit(mut self, smit: Option<String>) -> Self {
438 self.info.smit = smit;
439 self
440 }
441
442 /// Sets the instance slot; `None` when the peer daemon predates the field.
443 pub fn instance(mut self, instance: Option<u32>) -> Self {
444 self.info.instance = instance;
445 self
446 }
447
448 /// Sets whether this dog has handshaken with the shepherd; `None` for a
449 /// sheep, which has no handshake to report.
450 pub fn handshook(mut self, handshook: Option<bool>) -> Self {
451 self.info.handshook = handshook;
452 self
453 }
454
455 /// Sets whether the shepherd has given up restarting this dog; `None`
456 /// for a sheep, which is never given up on.
457 pub fn dog_stale(mut self, dog_stale: Option<bool>) -> Self {
458 self.info.dog_stale = dog_stale;
459 self
460 }
461
462 /// Sets the field names a load has parked for this sheep's next spawn;
463 /// `None` when nothing is parked.
464 pub fn pending(mut self, pending: Option<Vec<String>>) -> Self {
465 self.info.pending = pending;
466 self
467 }
468
469 /// Sets the field names an operator has overridden on this sheep;
470 /// `None` when there is nothing to report.
471 pub fn overridden(mut self, overridden: Option<Vec<String>>) -> Self {
472 self.info.overridden = overridden;
473 self
474 }
475
476 /// Sets the sheep's `max_memory` ceiling in bytes; `None` when it has no
477 /// ceiling configured.
478 pub fn max_memory(mut self, max_memory: Option<u64>) -> Self {
479 self.info.max_memory = max_memory;
480 self
481 }
482
483 /// Sets the sheep's declared level rules; empty when it declares none.
484 pub fn level_rules(mut self, level_rules: Vec<LevelRule>) -> Self {
485 self.info.level_rules = level_rules;
486 self
487 }
488
489 /// Sets one swap's own deadline in milliseconds; `None` on every row
490 /// but a reload's, and on a row that reload will not replace.
491 pub fn reload_deadline_ms(mut self, reload_deadline_ms: Option<u64>) -> Self {
492 self.info.reload_deadline_ms = reload_deadline_ms;
493 self
494 }
495
496 /// Sets whether the sheep's app is disabled; `None` when it is not.
497 pub fn disabled(mut self, disabled: Option<bool>) -> Self {
498 self.info.disabled = disabled;
499 self
500 }
501
502 /// Sets whether the sheep's app turns the shepherd's stamp off; `None`
503 /// for a dog, which always stamps.
504 pub fn log_timestamps(mut self, log_timestamps: Option<bool>) -> Self {
505 self.info.log_timestamps = log_timestamps;
506 self
507 }
508
509 /// Finishes the row.
510 #[must_use]
511 pub fn build(self) -> ProcessInfo {
512 self.info
513 }
514}
515
516#[cfg(test)]
517pub(super) fn sample_info() -> ProcessInfo {
518 ProcessInfo {
519 id: 3,
520 name: "web".to_string(),
521 status: ProcStatus::Online,
522 pid: Some(4242),
523 restarts: 1,
524 uptime_ms: 60_000,
525 fold: Some("backend".to_string()),
526 // Left empty: this fixture feeds `reply_wire_snapshots` and
527 // `bus_event_wire_snapshots`, so a non-empty value moves pinned
528 // bytes.
529 depends_on: Vec::new(),
530 out_file: Some("/home/ada/.shep/logs/web-0-out.log".to_string()),
531 err_file: Some("/home/ada/.shep/logs/web-0-err.log".to_string()),
532 // 12.5: an insta JSON snapshot is stable across platforms only
533 // for a float the binary representation holds exactly.
534 cpu_percent: Some(12.5),
535 memory_bytes: Some(48 * 1024 * 1024),
536 cpu_ms: None,
537 dog: None,
538 lambs: None,
539 last_exit: Some(ExitInfo {
540 code: Some(1),
541 signal: None,
542 }),
543 smit: None,
544 instance: None,
545 handshook: None,
546 dog_stale: None,
547 // Left at the builder's default: this fixture feeds
548 // `reply_wire_snapshots` and `bus_event_wire_snapshots`, so a
549 // `Some(..)` moves pinned bytes.
550 pending: None,
551 overridden: None,
552 max_memory: Some(512 * 1024 * 1024),
553 // Populated where its list-shaped neighbours above are not:
554 // nothing else on the wire pins a `LevelRule`'s field names or a
555 // `LineLevel` spelling, and an empty list would prove neither.
556 level_rules: vec![crate::config::LevelRule {
557 pattern: r"\[ERROR\]".to_string(),
558 level: crate::config::LineLevel::Error,
559 }],
560 // `None` here so the key is absent from the pinned bytes; the
561 // serialized shape of a real one is pinned on its own, by
562 // `a_reload_deadline_is_absent_from_the_wire_until_a_reload_sets_one`.
563 reload_deadline_ms: None,
564 // `None` so the key is absent from the pinned bytes; its serialized
565 // shape is pinned by `a_disabled_flag_is_absent_from_the_wire_until_set`.
566 disabled: None,
567 // `None` for the same reason, its shape pinned by
568 // `a_stamps_off_flag_is_absent_from_the_wire_until_set`.
569 log_timestamps: None,
570 }
571}
572
573#[cfg(test)]
574mod stamps;
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579
580 #[test]
581 fn a_builder_with_nothing_set_is_a_sheep_that_has_not_run() {
582 let info = ProcessInfo::builder(3, "web", ProcStatus::Stopped).build();
583
584 assert_eq!(info.id, 3);
585 assert_eq!(info.name, "web");
586 assert_eq!(info.status, ProcStatus::Stopped);
587 assert_eq!(info.pid, None);
588 assert_eq!(info.restarts, 0);
589 assert_eq!(info.uptime_ms, 0);
590 assert_eq!(info.fold, None);
591 assert_eq!(info.out_file, None);
592 assert_eq!(info.err_file, None);
593 assert_eq!(info.cpu_percent, None);
594 assert_eq!(info.memory_bytes, None);
595 assert_eq!(info.cpu_ms, None);
596 assert_eq!(info.dog, None);
597 assert_eq!(info.lambs, None);
598 assert_eq!(info.last_exit, None);
599 }
600
601 /// The raw counter rides the wire beside the percent. A client polling
602 /// faster than the shepherd's own sampling interval differences two of
603 /// these; `cpu_percent` cannot serve it, because consecutive readings
604 /// share a baseline.
605 #[test]
606 fn a_process_info_carries_the_cpu_counter() {
607 let info = ProcessInfo::builder(3, "web", ProcStatus::Online)
608 .cpu_ms(Some(1_234))
609 .build();
610 assert_eq!(info.cpu_ms, Some(1_234));
611 }
612
613 /// Absent by default, like every other sampled field: a lifecycle verb's
614 /// answer carries no reading.
615 #[test]
616 fn a_process_info_without_a_reading_has_no_counter() {
617 let info = ProcessInfo::builder(3, "web", ProcStatus::Online).build();
618 assert_eq!(info.cpu_ms, None);
619 }
620
621 /// Every field is given a value distinct from every other field's
622 /// default, so a copy-pasted setter body shows up as a mismatch.
623 #[test]
624 fn every_setter_writes_its_own_field_and_no_other() {
625 let built = ProcessInfo::builder(3, "web", ProcStatus::Online)
626 .pid(Some(4242))
627 .restarts(1)
628 .uptime_ms(60_000)
629 .fold(Some("backend".to_string()))
630 .out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
631 .err_file(Some("/home/ada/.shep/logs/web-0-err.log".to_string()))
632 .cpu_percent(Some(12.5))
633 .memory_bytes(Some(48 * 1024 * 1024))
634 .dog(None)
635 .last_exit(Some(ExitInfo {
636 code: Some(1),
637 signal: None,
638 }))
639 .max_memory(Some(512 * 1024 * 1024))
640 .level_rules(vec![crate::config::LevelRule {
641 pattern: r"\[ERROR\]".to_string(),
642 level: crate::config::LineLevel::Error,
643 }])
644 .build();
645
646 // `sample_info()` is a struct literal on purpose: it is the one
647 // place that names every field by hand, so this comparison fails the
648 // day the struct grows a field the builder cannot set.
649 assert_eq!(built, sample_info());
650
651 // `sample_info()`'s `dog` is `None`, the builder's default too, so an
652 // empty `dog` setter body would pass the comparison above. It cannot
653 // be changed: it feeds pinned snapshots.
654 assert_eq!(
655 ProcessInfo::builder(1, "metrics", ProcStatus::Online)
656 .dog(Some(DogSource::BuiltIn))
657 .build()
658 .dog,
659 Some(DogSource::BuiltIn),
660 "an empty `dog` setter body is invisible to the comparison above"
661 );
662
663 // `lambs`, on `dog`'s terms above.
664 assert_eq!(
665 ProcessInfo::builder(1, "web", ProcStatus::Online)
666 .lambs(Some(vec![Lamb::new(4243, "node")]))
667 .build()
668 .lambs,
669 Some(vec![Lamb::new(4243, "node")]),
670 "an empty `lambs` setter body is invisible to the comparison above"
671 );
672
673 // `smit`, on the same terms, and the field a third party writes: an
674 // empty setter body drops every dog's mark.
675 assert_eq!(
676 ProcessInfo::builder(1, "web", ProcStatus::Online)
677 .smit(Some("\u{25b2} main@a1b2c3".to_string()))
678 .build()
679 .smit
680 .as_deref(),
681 Some("\u{25b2} main@a1b2c3"),
682 "an empty `smit` setter body is invisible to the comparison above"
683 );
684
685 // `handshook`, on the same terms.
686 assert_eq!(
687 ProcessInfo::builder(1, "web", ProcStatus::Online)
688 .handshook(Some(false))
689 .build()
690 .handshook,
691 Some(false),
692 "an empty `handshook` setter body is invisible to the comparison above"
693 );
694
695 // `dog_stale`, paired with `handshook`: both default to `None`.
696 assert_eq!(
697 ProcessInfo::builder(1, "web", ProcStatus::Online)
698 .dog_stale(Some(true))
699 .build()
700 .dog_stale,
701 Some(true),
702 "an empty `dog_stale` setter body is invisible to the comparison above"
703 );
704
705 // `pending`, on the same terms.
706 assert_eq!(
707 ProcessInfo::builder(1, "web", ProcStatus::Online)
708 .pending(Some(vec!["env".to_string()]))
709 .build()
710 .pending,
711 Some(vec!["env".to_string()]),
712 "an empty `pending` setter body is invisible to the comparison above"
713 );
714
715 // `overridden`, on the same terms.
716 assert_eq!(
717 ProcessInfo::builder(1, "web", ProcStatus::Online)
718 .overridden(Some(vec!["cwd".to_string()]))
719 .build()
720 .overridden,
721 Some(vec!["cwd".to_string()]),
722 "an empty `overridden` setter body is invisible to the comparison above"
723 );
724 }
725
726 #[test]
727 fn lambs_distinguishes_not_walked_from_walked_and_empty() {
728 let not_walked = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
729 assert_eq!(not_walked.lambs, None);
730
731 let walked_empty = ProcessInfo::builder(1, "web", ProcStatus::Online)
732 .lambs(Some(Vec::new()))
733 .build();
734 assert_eq!(walked_empty.lambs, Some(Vec::new()));
735 }
736
737 #[test]
738 fn a_process_info_without_a_lambs_key_still_deserializes() {
739 let fixture = r#"{
740 "id": 3, "name": "web", "status": "online", "pid": 4242,
741 "restarts": 0, "uptime_ms": 100, "fold": null,
742 "out_file": null, "err_file": null,
743 "cpu_percent": null, "memory_bytes": null, "dog": null
744 }"#;
745 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
746 assert_eq!(info.lambs, None);
747 }
748
749 /// argv holds credentials (`--password=`, `?token=`) and
750 /// `shep describe --format json` is output people paste into issues.
751 #[test]
752 fn a_lamb_is_a_pid_and_an_executable_name() {
753 let lamb = Lamb::new(4243, "node");
754 let json = serde_json::to_string(&lamb).unwrap();
755 assert_eq!(json, r#"{"pid":4243,"name":"node"}"#);
756 assert_eq!(serde_json::from_str::<Lamb>(&json).unwrap(), lamb);
757 }
758
759 #[test]
760 fn a_labelled_lamb_carries_the_label_beside_the_name() {
761 let lamb = Lamb::new(4243, "python").with_label("worker 1");
762 let json = serde_json::to_string(&lamb).unwrap();
763 assert_eq!(json, r#"{"pid":4243,"name":"python","label":"worker 1"}"#);
764 assert_eq!(serde_json::from_str::<Lamb>(&json).unwrap(), lamb);
765 }
766
767 #[test]
768 fn v1_process_info_without_a_dog_marker_still_deserializes() {
769 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}"#;
770 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
771 assert_eq!(info.dog, None);
772 }
773
774 /// No field here carries `#[serde(default)]`: serde's derive resolves a
775 /// missing key to `None` for a field whose type is syntactically
776 /// `Option<...>`.
777 #[test]
778 fn a_process_info_without_a_last_exit_key_still_deserializes() {
779 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}"#;
780 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
781 assert_eq!(info.last_exit, None);
782 }
783
784 #[test]
785 fn a_process_info_without_a_smit_key_still_deserializes() {
786 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}"#;
787 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
788 assert_eq!(info.smit, None);
789 }
790
791 /// The fixture is a dog's row, where `None` means "render this as it
792 /// rendered before the field existed", never "never handshaken".
793 #[test]
794 fn a_process_info_without_a_handshook_key_still_deserializes() {
795 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}"#;
796 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
797 assert_eq!(info.handshook, None);
798 assert_eq!(info.dog, Some(DogSource::BuiltIn));
799 }
800
801 /// The fixture carries `handshook: false`, the case that matters: `None`
802 /// is "no verdict to report", never "it has not given up".
803 #[test]
804 fn a_process_info_without_a_dog_stale_key_still_deserializes() {
805 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}"#;
806 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
807 assert_eq!(info.dog_stale, None);
808 assert_eq!(info.handshook, Some(false));
809 }
810
811 #[test]
812 fn a_process_info_carries_its_memory_ceiling_and_defaults_to_none() {
813 let plain = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
814 assert_eq!(
815 plain.max_memory, None,
816 "a sheep with no ceiling reports none"
817 );
818
819 let capped = ProcessInfo::builder(2, "hungry", ProcStatus::Online)
820 .max_memory(Some(52 * 1024 * 1024))
821 .build();
822 assert_eq!(capped.max_memory, Some(54_525_952));
823 }
824
825 /// Absent rather than `null` when there is no reload, so a listing's
826 /// payload is the shape it always was and `SCHEMA_VERSION` stays put.
827 /// Present as a plain number when a reload sets one, which is the only
828 /// place a client reads it.
829 #[test]
830 fn a_reload_deadline_is_absent_from_the_wire_until_a_reload_sets_one() {
831 let quiet = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
832 let json = serde_json::to_string(&quiet).expect("serialize");
833 assert!(
834 !json.contains("reload_deadline_ms"),
835 "a row with no reload carries no key at all: {json}"
836 );
837
838 let reloading = ProcessInfo::builder(1, "web", ProcStatus::Online)
839 .reload_deadline_ms(Some(16_000))
840 .build();
841 let json = serde_json::to_string(&reloading).expect("serialize");
842 assert!(
843 json.contains(r#""reload_deadline_ms":16000"#),
844 "a reload's own row carries the number: {json}"
845 );
846 let back: ProcessInfo = serde_json::from_str(&json).expect("round trip");
847 assert_eq!(back.reload_deadline_ms, Some(16_000));
848 }
849
850 /// A disabled sheep stays `stopped` on the wire and gains a key beside it;
851 /// every other row is the bytes it always was.
852 #[test]
853 fn a_disabled_flag_is_absent_from_the_wire_until_set() {
854 let plain = ProcessInfo::builder(1, "web", ProcStatus::Stopped).build();
855 let json = serde_json::to_string(&plain).expect("serialize");
856 assert!(!json.contains("disabled"), "{json}");
857
858 let parked = ProcessInfo::builder(1, "web", ProcStatus::Stopped)
859 .disabled(Some(true))
860 .build();
861 let json = serde_json::to_string(&parked).expect("serialize");
862 assert!(json.contains(r#""status":"stopped""#), "{json}");
863 assert!(json.contains(r#""disabled":true"#), "{json}");
864 let back: ProcessInfo = serde_json::from_str(&json).expect("round trip");
865 assert_eq!(back.disabled, Some(true));
866 }
867
868 #[test]
869 fn an_older_daemons_process_info_still_decodes() {
870 // Every additive field, asserted off one payload. A payload written
871 // before any of them existed has to decode with each absent rather
872 // than fail the whole envelope, and one fixture cannot drift from
873 // another.
874 let older = r#"{"id":1,"name":"web","status":"online","restarts":0,"uptime_ms":0}"#;
875 let info: ProcessInfo = serde_json::from_str(older).expect("an older payload decodes");
876 assert_eq!(info.max_memory, None);
877 assert_eq!(info.reload_deadline_ms, None);
878 assert_eq!(info.disabled, None);
879 assert_eq!(info.log_timestamps, None);
880 }
881
882 #[test]
883 fn v1_process_info_without_stats_still_deserializes() {
884 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"}"#;
885 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
886 assert_eq!(info.cpu_percent, None);
887 assert_eq!(info.memory_bytes, None);
888 }
889
890 #[test]
891 fn v1_process_info_without_log_paths_still_deserializes() {
892 // Committed byte fixture from before `out_file`/`err_file` existed.
893 let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend"}"#;
894 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
895 assert_eq!(info.id, 3);
896 assert_eq!(info.out_file, None);
897 assert_eq!(info.err_file, None);
898 }
899
900 #[test]
901 fn an_old_client_still_decodes_a_new_process_info() {
902 // `ProcessInfo` carries no `deny_unknown_fields`, unlike the config
903 // types in `crate::config`, so extra keys are ignored.
904 #[derive(Deserialize)]
905 struct V1ProcessInfo {
906 id: u32,
907 fold: Option<String>,
908 }
909
910 let current = serde_json::to_string(&sample_info()).unwrap();
911 let old: V1ProcessInfo = serde_json::from_str(¤t).unwrap();
912 assert_eq!(old.id, 3);
913 assert_eq!(old.fold.as_deref(), Some("backend"));
914 }
915
916 /// The fixture cannot agree under either candidate order: by id it is
917 /// `web/1, api/2, web/0`, by name `api, web, web`. The two `web` rows
918 /// are the tiebreak half, seeded out of order.
919 #[test]
920 fn a_listing_sorts_by_name_then_by_id() {
921 let mut listing = vec![
922 ProcessInfo::builder(1, "web", ProcStatus::Online).build(),
923 ProcessInfo::builder(2, "api", ProcStatus::Online).build(),
924 ProcessInfo::builder(0, "web", ProcStatus::Online).build(),
925 ];
926 sort_flock(&mut listing);
927
928 let seen: Vec<(&str, u32)> = listing
929 .iter()
930 .map(|info| (info.name.as_str(), info.id))
931 .collect();
932 assert_eq!(
933 seen,
934 vec![("api", 2), ("web", 0), ("web", 1)],
935 "name first, then id inside a name"
936 );
937 }
938
939 #[test]
940 fn an_instance_slot_survives_a_round_trip_and_defaults_to_absent() {
941 let with = ProcessInfo::builder(1, "web", ProcStatus::Online)
942 .instance(Some(2))
943 .build();
944 assert_eq!(with.instance, Some(2));
945
946 let without = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
947 assert_eq!(
948 without.instance, None,
949 "a row nobody set a slot on says so, rather than claiming slot 0"
950 );
951 }
952
953 #[test]
954 fn a_reply_from_a_daemon_without_the_field_deserializes_as_absent() {
955 let json = r#"{"id":1,"name":"web","status":"online","pid":null,
956 "restarts":0,"uptime_ms":0,"fold":null,"out_file":null,
957 "err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":null,
958 "lambs":null,"last_exit":null,"smit":null}"#;
959 let info: ProcessInfo = serde_json::from_str(json).expect("older reply still parses");
960 assert_eq!(info.instance, None);
961 }
962
963 #[test]
964 fn sort_flock_orders_by_slot_before_id() {
965 // A reload gave slot 0 a fresh, higher id. Slot order must still win.
966 let mut listing = vec![
967 ProcessInfo::builder(9, "web", ProcStatus::Online)
968 .instance(Some(0))
969 .build(),
970 ProcessInfo::builder(2, "web", ProcStatus::Online)
971 .instance(Some(1))
972 .build(),
973 ];
974 sort_flock(&mut listing);
975 assert_eq!(
976 listing.iter().map(|i| i.id).collect::<Vec<_>>(),
977 vec![9, 2],
978 "slot 0 leads even though its id is higher"
979 );
980 }
981
982 #[test]
983 fn sort_flock_falls_back_to_id_when_no_row_carries_a_slot() {
984 let mut listing = vec![
985 ProcessInfo::builder(5, "web", ProcStatus::Online).build(),
986 ProcessInfo::builder(3, "web", ProcStatus::Online).build(),
987 ];
988 sort_flock(&mut listing);
989 assert_eq!(
990 listing.iter().map(|i| i.id).collect::<Vec<_>>(),
991 vec![3, 5],
992 "an older daemon's listing sorts exactly as it does today"
993 );
994 }
995}