shep_core/protocol/request.rs
1//! RPC frames: requests, responses, envelopes, and structured errors
2
3use core::fmt;
4
5use serde::{Deserialize, Deserializer, Serialize};
6
7use crate::config::{AppConfig, DeclaredApp, ResetDepth};
8use crate::status::ProcStatus;
9
10/// Client's opening frame
11///
12/// No `deny_unknown_fields`: refusing an unknown field here would refuse a
13/// newer client before `protocol` is read.
14// wire format: changing this is a breaking change
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct Hello {
17 /// Client crate version (semver string)
18 pub client_version: String,
19 /// [`crate::protocol::PROTOCOL_VERSION`] the client speaks
20 pub protocol: u32,
21 /// The name this client was registered under as a dog, when it is one.
22 ///
23 /// `None` for every other client; a bare `Client` cannot set it. The
24 /// daemon needs it to name a dog it refuses at the handshake, which never
25 /// reaches `Request::DogConfig`. A dog reads its own name from
26 /// `$SHEP_DOG_NAME`.
27 ///
28 /// Absent on the wire rather than `null`, so
29 /// [`crate::protocol::PROTOCOL_VERSION`] does not move for it.
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub dog_name: Option<String>,
32}
33
34/// Daemon's handshake answer
35// wire format: changing this is a breaking change
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct HelloAck {
38 /// Daemon crate version
39 pub daemon_version: String,
40 /// Protocol version the daemon speaks
41 pub protocol: u32,
42 /// Daemon pid
43 pub pid: u32,
44}
45
46/// Serializable selector (mirror of [`crate::selector::ProcessSelector`];
47/// regex travels as its source string)
48// wire format: changing this is a breaking change
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
51pub enum SelectorSpec {
52 /// Every sheep
53 All,
54 /// By id
55 Id(u32),
56 /// By exact name
57 Name(String),
58 /// By regex source
59 Regex(String),
60 /// By fold name
61 Fold(String),
62 // Both field names are wire contract, pinned by `request_wire_v3`.
63 /// By app name and instance slot
64 ///
65 /// On the wire: `{"kind":"instance","value":{"name":"web","slot":2}}`.
66 Instance {
67 /// The app name
68 name: String,
69 /// The instance slot, counting from 0
70 slot: u32,
71 },
72}
73
74/// A short marker a dog attaches to a sheep for `shep flock` to paint.
75///
76/// shep stores and prints it, never parses it: `▲ main@a1b2c3` is a
77/// deploy tool's sentence.
78///
79/// The grammar: non-empty once whitespace is discounted, at most
80/// [`Self::MAX_CHARS`] characters, no [`char::is_control`] character,
81/// `\u{1b}` included. Refused, never repaired, and validated here rather
82/// than at the renderer: `shep`'s own `output::width::sanitize_cell` keeps
83/// a well-formed CSI sequence, since shep's colouring is made of them.
84///
85/// [`Self::MAX_CHARS`] counts `char`s, not bytes: a byte cap would refuse a
86/// legitimate CJK smit at roughly a third of its apparent length.
87///
88/// `Debug` is derived: a smit carries no secret, so there is nothing to
89/// redact.
90///
91/// # Example
92/// ```
93/// use shep_core::protocol::Smit;
94///
95/// assert_eq!("▲ main@a1b2c3".parse::<Smit>()?.as_str(), "▲ main@a1b2c3");
96/// assert!("\u{1b}[2Jgone".parse::<Smit>().is_err()); // no escapes
97/// # Ok::<(), shep_core::protocol::SmitError>(())
98/// ```
99// wire format: changing this is a breaking change
100#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
101pub struct Smit(String);
102
103impl Smit {
104 /// The longest a smit may be, in characters.
105 pub const MAX_CHARS: usize = 48;
106
107 /// The marker as text, exactly as its publisher sent it.
108 #[must_use]
109 pub fn as_str(&self) -> &str {
110 &self.0
111 }
112}
113
114impl fmt::Display for Smit {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 f.write_str(&self.0)
117 }
118}
119
120impl core::str::FromStr for Smit {
121 type Err = SmitError;
122
123 /// # Errors
124 /// - [`SmitError::Empty`] if the text is nothing but whitespace.
125 /// - [`SmitError::TooLong`] if it is over [`Self::MAX_CHARS`] characters.
126 /// - [`SmitError::Unprintable`] if it holds a control character.
127 fn from_str(text: &str) -> Result<Self, Self::Err> {
128 if text.trim().is_empty() {
129 return Err(SmitError::Empty);
130 }
131 let chars = text.chars().count();
132 if chars > Self::MAX_CHARS {
133 return Err(SmitError::TooLong { chars });
134 }
135 if text.chars().any(char::is_control) {
136 return Err(SmitError::Unprintable);
137 }
138 Ok(Self(text.to_string()))
139 }
140}
141
142/// Validates on decode: a dog written in another language speaks this wire
143/// directly and never runs [`core::str::FromStr`], so a derived impl would
144/// let `\u{1b}[2J` reach every listing built from a smit.
145impl<'de> Deserialize<'de> for Smit {
146 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
147 // String, not &str: a non-borrowing deserializer cannot always borrow
148 let text = String::deserialize(deserializer)?;
149 text.parse().map_err(serde::de::Error::custom)
150 }
151}
152
153/// Why a string is not a [`Smit`].
154#[non_exhaustive]
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum SmitError {
157 /// Over [`Smit::MAX_CHARS`] characters; carries the count that was sent.
158 TooLong {
159 /// How many characters the string held.
160 chars: usize,
161 },
162 /// A control character, `\u{1b}` included.
163 Unprintable,
164 /// Empty, or nothing but whitespace.
165 Empty,
166}
167
168impl fmt::Display for SmitError {
169 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170 match self {
171 Self::TooLong { chars } => write!(
172 f,
173 "a smit is at most {} characters; this one is {chars}",
174 Smit::MAX_CHARS
175 ),
176 Self::Unprintable => {
177 f.write_str("a smit may not contain a control character, an escape included")
178 }
179 Self::Empty => f.write_str("a smit may not be empty"),
180 }
181 }
182}
183
184impl core::error::Error for SmitError {}
185
186/// One RPC request
187// wire format: changing existing variants is a breaking change
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189#[serde(tag = "kind", rename_all = "snake_case")]
190#[non_exhaustive]
191pub enum Request {
192 /// Liveness check
193 Ping,
194 /// Full flock listing
195 ListFlock,
196 /// Detailed info for matching sheep
197 Describe {
198 /// Which sheep
199 selector: SelectorSpec,
200 },
201 /// Register + start apps
202 Start {
203 /// App configs. The daemon must re-normalize them, since peer input
204 /// is untrusted; failures return [`RpcErrorCode::InvalidConfig`]
205 apps: Vec<AppConfig>,
206 },
207 /// Register apps as flock members without starting any of them
208 ///
209 /// Each app lands `Stopped` and holds no pid; `shep add` is the verb.
210 ///
211 /// Idempotent by name: an app the flock already has is answered as it
212 /// stands, running or not, and nothing about it changes.
213 /// [`Self::ApplyConfig`] merges a template into one the flock already
214 /// has, and `shep add` sends both.
215 ///
216 /// Answers [`Response::Added`].
217 Add {
218 /// App configs, carried exactly as [`Self::Start`] carries them. The
219 /// daemon must re-normalize them, since peer input is untrusted;
220 /// failures return [`RpcErrorCode::InvalidConfig`]
221 apps: Vec<AppConfig>,
222 },
223 /// Ask which of `apps` name a sheep the flock already has under a
224 /// different config
225 ///
226 /// Read-only. [`Self::Start`] on an already-registered name adds
227 /// instances rather than reconciling config.
228 ///
229 /// Answers [`Response::Drifted`] with one [`SheepDrift`] per app that is
230 /// both registered and different. An app the flock does not have is
231 /// absent from the answer, not reported as unchanged.
232 ConfigDrift {
233 /// The configs to compare against, exactly as [`Self::Start`] would
234 /// carry them. The daemon must re-normalize them: peer input is
235 /// untrusted, and an unnormalized config would report every default
236 /// it has not spelled out as a difference. Failures return
237 /// [`RpcErrorCode::InvalidConfig`].
238 apps: Vec<AppConfig>,
239 },
240 /// Merge each declared app into the sheep of the same name, applying
241 /// what can be applied and parking the rest for that sheep's next spawn
242 ///
243 /// Nothing is registered, nothing is pruned and nothing running is
244 /// killed: an app the flock does not have is refused by name, and a
245 /// field the running child was spawned from waits for a `shep reload`.
246 /// Additive by default; `reset` widens it.
247 ///
248 /// Answers [`Response::Applied`] with one [`SheepApplied`] per entry in
249 /// `apps`, in the order given, found or not and changed or not. One
250 /// app's refusal rides in [`SheepApplied::refused`] and does not cost
251 /// the rest of the file its load.
252 ApplyConfig {
253 /// The apps to merge in, each carrying the keys its document
254 /// literally wrote. The daemon must re-normalize the merge result,
255 /// since peer input is untrusted, and refuses the whole request with
256 /// [`RpcErrorCode::InvalidConfig`] when two entries share a name:
257 /// the second would be merged against a store the first has not
258 /// written yet.
259 apps: Vec<DeclaredApp>,
260 /// How much of what the operator has set since a template last
261 /// loaded this request may overwrite. Default [`ResetDepth::None`],
262 /// which overwrites nothing.
263 ///
264 /// Spelled `none`/`file`/`env`/`policy` on the wire.
265 reset: ResetDepth,
266 },
267 /// Stop matching sheep (stay registered)
268 Stop {
269 /// Which sheep
270 selector: SelectorSpec,
271 },
272 /// Restart matching sheep
273 Restart {
274 /// Which sheep
275 selector: SelectorSpec,
276 },
277 /// Replace each matching sheep with a fresh instance of the same app, one
278 /// instance of an app at a time, so the app has a window in which it can
279 /// stay reachable across the swap
280 Reload {
281 /// Which sheep. No default: a reload replaces running processes.
282 selector: SelectorSpec,
283 },
284 /// Stop + deregister matching sheep
285 Delete {
286 /// Which sheep
287 selector: SelectorSpec,
288 },
289 /// Set how many instances one app runs (see `shep stock`).
290 ///
291 /// Takes a name where every other verb takes a [`SelectorSpec`]:
292 /// `instances` is a per-app number and slots are allocated against the
293 /// same-name group, so a selector matching two apps could mean four of
294 /// each or four in total.
295 ///
296 /// The count is absolute: two operators sending `+2` against the same
297 /// app would get a number neither asked for.
298 Scale {
299 /// The app's name, exactly as its config spells it. Not a selector: no
300 /// `all`, no regex, no `fold:`.
301 name: String,
302 /// How many instances the app has when this returns. `0` is refused
303 /// with [`RpcErrorCode::InvalidConfig`]: `shep delete` is the verb
304 /// for removing an app.
305 count: u32,
306 },
307 /// Attach a short marker to `sheep` for `shep flock` to paint, or clear
308 /// it with `None`.
309 ///
310 /// By name, not a selector: a smit belongs to a sheep, and every
311 /// instance of that name shows it, one spawned after the paint included.
312 ///
313 /// Held in memory and scoped to the connection that sent it, so a
314 /// publisher republishes rather than publishing on change.
315 SetSmit {
316 /// Which sheep.
317 sheep: String,
318 /// The marker, or `None` to clear it.
319 smit: Option<Smit>,
320 },
321 /// Reopen every matched sheep's log files, for an external rotator that
322 /// has renamed them (`create`-mode rotation)
323 Reopen {
324 /// Which sheep
325 selector: SelectorSpec,
326 },
327 /// Empty every matched sheep's log files: flush what is still pending,
328 /// then truncate the recorded paths
329 Flush {
330 /// Which sheep. No default: this destroys log data.
331 selector: SelectorSpec,
332 },
333 /// Send a named action to every matched sheep over its shepherd channel
334 /// and report what each app says back (see `shep trigger`).
335 Trigger {
336 /// Which sheep. No default, matching every other verb that reaches
337 /// a running process.
338 selector: SelectorSpec,
339 /// The action name. Free-form: the daemon never declares, parses, or
340 /// validates it, and an app that does not recognize the name is
341 /// expected to say so in its own reply.
342 action: String,
343 /// Argument text, passed through to the app verbatim. One opaque
344 /// string, matching the shepherd channel's own `action` message this
345 /// becomes.
346 params: Option<String>,
347 },
348 /// Deliver one signal to every matched sheep's own process, never its
349 /// process group (see `shep signal`).
350 Signal {
351 /// Which sheep. No default, matching every other verb that reaches
352 /// a running process.
353 selector: SelectorSpec,
354 /// The signal's name, as
355 /// [`OperatorSignal`](crate::signals::OperatorSignal) spells it. The
356 /// `SIG` prefix and the case are both optional; a name outside the
357 /// grammar answers [`RpcErrorCode::InvalidConfig`].
358 signal: String,
359 },
360 /// Write one line to every matched sheep's stdin (see `shep whisper`).
361 SendLine {
362 /// Which sheep. No default, matching every other verb that reaches a
363 /// running process.
364 selector: SelectorSpec,
365 /// The line, without its terminator: the shepherd appends exactly
366 /// one `\n` when it writes.
367 ///
368 /// A line containing an embedded newline is refused
369 /// ([`RpcErrorCode::InvalidConfig`]): it would deliver two commands
370 /// where the operator typed one.
371 line: String,
372 },
373 /// Write the muster roll now, bypassing the snapshot writer's debounce
374 SaveRoll,
375 /// Assemble the flock from the muster roll on disk: start every app the
376 /// roll recorded running, leaving every app the flock already has exactly
377 /// as it stands
378 Muster,
379 /// Ask for one dog's `[dog.<name>]` section, as the dog itself parses it
380 DogConfig {
381 /// The dog's name: the config key, not a selector
382 name: String,
383 },
384 /// Start one dog now, marking it as coming from `source`
385 EnableDog {
386 /// The dog's name
387 name: String,
388 /// Where its binary comes from
389 source: DogSource,
390 },
391 /// Stop and deregister one dog
392 ///
393 /// Answers [`Response::Deleted`]: disabling deregisters exactly as
394 /// `Delete` does.
395 DisableDog {
396 /// The dog's name
397 name: String,
398 },
399 /// Ask which dogs this daemon has given up on, and which it is still
400 /// waiting to hear from (`shep daemon reload`).
401 ///
402 /// Read-only, and about this daemon's own handshakes: take the reading
403 /// after a reload, not before one. Never sent to an older daemon, on
404 /// [`Self::HandoverFitness`]'s terms.
405 ///
406 /// Answers [`Response::DogStaleness`].
407 DogStaleness,
408 /// Ask whether this daemon could hand its flock to a successor in place,
409 /// rather than stopping it and starting it again (`shep daemon reload`).
410 ///
411 /// Read-only: the handover itself is triggered by a signal, which reaches
412 /// a daemon that refuses the client at the handshake.
413 ///
414 /// Answers [`Response::HandoverFitness`]. A refusal is a feature the
415 /// running daemon cannot carry, not an error: the caller falls back to a
416 /// stop-and-start and prints the reason. Never sent to an older daemon:
417 /// shep-cli's `commands::daemon` gates it on the crate version the
418 /// handshake reported.
419 HandoverFitness,
420 /// Graceful daemon shutdown
421 KillDaemon,
422 /// Subscribe this connection to bus topics (glob patterns)
423 Subscribe {
424 /// Topic globs, e.g. `process.*`
425 topics: Vec<String>,
426 },
427}
428
429/// Where a dog came from: this binary, or one an operator adopted.
430///
431/// Carried on [`ProcessInfo::dog`], so a listing distinguishes the two
432/// populations without a second request.
433// wire format: changing existing variants is a breaking change
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
435#[serde(tag = "kind", rename_all = "snake_case")]
436#[non_exhaustive]
437pub enum DogSource {
438 /// An argv branch of the shep binary itself (`shep dog <name>`).
439 BuiltIn,
440 /// A binary an operator adopted, run at the daemon's own trust level.
441 Adopted {
442 /// The binary's path, exactly as the operator gave it to `adopt`.
443 path: String,
444 },
445}
446
447/// One process the OS reports as a descendant of a sheep.
448///
449/// Not the set of processes that die with the sheep: this is a parent-pid
450/// walk, where the stop ladder acts on the process group. A lamb that forks
451/// and exits leaves children re-parented to init, out of this list and still
452/// in the group; a `setsid()` grandchild stays in the list and leaves the
453/// group.
454///
455/// `name` is the executable's name (`node`, `sh`), never argv, which carries
456/// credentials and would ride into `shep describe --format json`. Build one
457/// with [`Self::new`].
458// wire format: changing this is a breaking change
459#[non_exhaustive]
460#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
461pub struct Lamb {
462 /// The lamb's own pid.
463 pub pid: u32,
464 /// The executable's name, as the OS reports it. Never its command line.
465 pub name: String,
466}
467
468impl Lamb {
469 /// One lamb.
470 #[must_use]
471 pub fn new(pid: u32, name: impl Into<String>) -> Self {
472 Self {
473 pid,
474 name: name.into(),
475 }
476 }
477}
478
479/// Why a sheep's process most recently stopped existing under this daemon.
480///
481/// Behind [`ProcessInfo::last_exit`]'s own `Option`, so `None` there means
482/// never exited. Ordinarily exactly one of `code`/`signal` is `Some`,
483/// mirroring the OS's `WIFEXITED`/`WIFSIGNALED` split; both `None` together
484/// is legal and means this daemon recorded an exit it could not
485/// characterize.
486// wire format: changing this is a breaking change
487#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
488pub struct ExitInfo {
489 /// The process's own exit code, set on a normal exit (`WIFEXITED`).
490 pub code: Option<i32>,
491 /// The raw unix signal number that ended the process, set when it did
492 /// not exit on its own (`WIFSIGNALED`). An operator's own `shep stop`
493 /// counts: the process still stopped by a signal.
494 ///
495 /// Platform-specific, and never rendered as a name here; that is an
496 /// OS-aware layer's job.
497 pub signal: Option<i32>,
498}
499
500/// Snapshot of one sheep for listings and events
501///
502/// Construct one with [`ProcessInfo::builder`]: `#[non_exhaustive]` forbids
503/// a struct literal outside this crate, though not inside it. The fields
504/// stay `pub`.
505// wire format: changing this is a breaking change. No `Eq`: `cpu_percent` is
506// an `f32`. Paths travel as `String`, since serde's `PathBuf` refuses a
507// non-UTF-8 path and would blank a whole `Reply`. Every added field is an
508// `Option`, so a peer built before it sends no key and `None` reads as unknown.
509#[non_exhaustive]
510#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
511pub struct ProcessInfo {
512 /// Stable numeric id
513 pub id: u32,
514 /// Sheep name
515 pub name: String,
516 /// Lifecycle status
517 pub status: ProcStatus,
518 /// OS pid while running
519 pub pid: Option<u32>,
520 /// Restart count since registration
521 pub restarts: u32,
522 /// Milliseconds since last successful start
523 pub uptime_ms: u64,
524 /// Fold membership
525 pub fold: Option<String>,
526 /// Resolved stdout log path: the app's explicit
527 /// [`AppConfig::out_file`] when it set one, else the daemon-derived
528 /// default. `None` only when the peer daemon predates this field.
529 pub out_file: Option<String>,
530 /// Resolved stderr log path, resolved exactly as [`Self::out_file`]
531 pub err_file: Option<String>,
532 /// Tree CPU as a percentage of one core, over the window since the
533 /// daemon's last periodic sample. `None` when the sheep is not running,
534 /// when it has been up for less than one sampling window, or when the
535 /// peer daemon predates the field; all three render as unknown, never as
536 /// zero. A value over 100 is a tree using more than one core.
537 pub cpu_percent: Option<f32>,
538 /// Tree resident set size in bytes, current as of the reply. `None`
539 /// under the same three conditions as [`Self::cpu_percent`], minus the
540 /// window one: memory needs no baseline.
541 pub memory_bytes: Option<u64>,
542 /// Set when this entry is a dog, naming where the dog came from;
543 /// `None` for a sheep.
544 ///
545 /// Two cases, not [`Self::cpu_percent`]'s three: "not a dog" is the true
546 /// answer whether the peer predates the field or the entry is a sheep.
547 pub dog: Option<DogSource>,
548 /// The processes the OS reports as descendants of this sheep, or `None`
549 /// when this reply did not walk for them.
550 ///
551 /// `None` covers two cases: this reply is not a `Describe` (only
552 /// `Describe` walks), or the peer daemon predates the field.
553 /// `Some(vec![])` is the third: walked, and this sheep has no children.
554 ///
555 /// Read [`Lamb`]'s own doc before rendering this: the list is not the set
556 /// of processes a stop kills, and any output built from it has to say so.
557 pub lambs: Option<Vec<Lamb>>,
558 /// How this sheep's process most recently stopped existing under this
559 /// daemon. `None` while it has never exited under this daemon, and when
560 /// the peer daemon predates the field.
561 ///
562 /// Sticky across a respawn: it answers why the sheep last stopped, not
563 /// whether it is stopped now, and updates only on the next exit.
564 pub last_exit: Option<ExitInfo>,
565 /// The marker a dog has asked to have painted beside this sheep, or
566 /// `None` when no dog has painted one, which also covers a peer daemon
567 /// that predates the field.
568 ///
569 /// A `String` rather than a [`Smit`]: this is a report, and the
570 /// validation that makes it safe to print happened at the daemon's
571 /// ingress. Every instance of a name shows the same marker, since smits
572 /// are keyed by sheep name.
573 pub smit: Option<String>,
574 /// Which instance slot of its app this sheep occupies, counting from 0.
575 ///
576 /// `None` when the peer daemon predates the field. Not a bare `u32`
577 /// defaulted to 0: an app stocked to four instances would report four
578 /// rows all claiming slot 0.
579 pub instance: Option<u32>,
580 /// Whether this dog has completed a handshake with the shepherd that is
581 /// reporting it, and not been refused since; `None` for a sheep.
582 ///
583 /// `None` on [`Self::dog`]'s two-case terms: a sheep has no connection
584 /// to the shepherd, so "no handshake fact to report" is the true answer
585 /// for a sheep and for a peer that predates the field alike.
586 ///
587 /// `Some(false)` is the one that matters: a dog on a protocol this
588 /// shepherd refuses is alive, which is all [`Self::status`] reports, and
589 /// not doing its job. A fact and not a verdict, though: a dog spawned a
590 /// moment ago has not handshaken yet and is healthy.
591 pub handshook: Option<bool>,
592 /// Whether the reporting shepherd has given up on this dog: restarted it
593 /// once for never answering, watched that not help, and stopped
594 /// restarting it. `None` for a sheep, on [`Self::handshook`]'s terms.
595 ///
596 /// Not derivable from [`Self::handshook`]. `Some(false)` there covers
597 /// both a dog spawned three seconds ago that has not dialled back and one
598 /// this shepherd has permanently stopped restarting; the first needs
599 /// nothing done and the second is an incident.
600 ///
601 /// A fact and not a verdict: it says the shepherd stopped, never why.
602 /// The why is in that dog's own log (`shep bleats <dog>`).
603 pub dog_stale: Option<bool>,
604 /// The [`AppConfig`] field names this sheep's spec differs from a load's
605 /// parked config for, in field-name order. `None` when nothing is parked,
606 /// and when the peer daemon predates the field.
607 ///
608 /// Names only, never values, as [`SheepDrift::fields`] carries them: a
609 /// differing `env` reports `"env"` and stops there. `shep reload`
610 /// promotes a parked config.
611 #[serde(default, skip_serializing_if = "Option::is_none")]
612 pub pending: Option<Vec<String>>,
613 /// The [`AppConfig`] field names an operator has set on this sheep that
614 /// its current Flockfile does not declare, in field-name order. `None`
615 /// when there is nothing to report, and when the peer daemon predates
616 /// the field.
617 ///
618 /// Names only, never values, for [`Self::pending`]'s reason:
619 /// [`crate::overrides::AppOverrides::fields`] can hold an `env` value.
620 #[serde(default, skip_serializing_if = "Option::is_none")]
621 pub overridden: Option<Vec<String>>,
622}
623
624/// Orders one flock listing the way every operator-facing surface presents
625/// one: `(name, instance, id)`.
626///
627/// Name first: an id is assigned at registration and a `delete all` plus a
628/// fresh start renumbers the flock, where a name survives. A name is not a
629/// total order on its own, so the id breaks the tie and stays an addressing
630/// key (`shep stop 11`).
631///
632/// A listing whose rows all carry `None` for the slot collapses to
633/// `(name, id)`, since `None` sorts before every `Some`.
634pub fn sort_flock(listing: &mut [ProcessInfo]) {
635 listing.sort_unstable_by(|a, b| {
636 (a.name.as_str(), a.instance, a.id).cmp(&(b.name.as_str(), b.instance, b.id))
637 });
638}
639
640impl ProcessInfo {
641 /// Starts a builder for one sheep's row.
642 ///
643 /// The three arguments are the fields no row can omit and no reader can
644 /// default.
645 ///
646 /// No `#[must_use]`: [`ProcessInfoBuilder`] carries one, which clippy's
647 /// `double_must_use` lint treats as covering this return too.
648 pub fn builder(id: u32, name: impl Into<String>, status: ProcStatus) -> ProcessInfoBuilder {
649 ProcessInfoBuilder {
650 info: Self {
651 id,
652 name: name.into(),
653 status,
654 pid: None,
655 restarts: 0,
656 uptime_ms: 0,
657 fold: None,
658 out_file: None,
659 err_file: None,
660 cpu_percent: None,
661 memory_bytes: None,
662 dog: None,
663 lambs: None,
664 last_exit: None,
665 smit: None,
666 instance: None,
667 handshook: None,
668 dog_stale: None,
669 pending: None,
670 overridden: None,
671 },
672 }
673 }
674}
675
676/// Builds a [`ProcessInfo`], which is `#[non_exhaustive]` and so cannot be
677/// written as a struct literal outside this crate.
678///
679/// Every setter takes the field's own type, `Option` included, so a caller
680/// already holding `Option<u32>` writes `.pid(entry.pid())` rather than an
681/// `if let` ladder. A setter is skipped, not passed `None`, when a row has
682/// nothing to say about that field; the skipped defaults are the ones a
683/// not-yet-running sheep has.
684#[derive(Debug, Clone)]
685#[must_use = "a builder that is never `build`-ed produces no ProcessInfo"]
686pub struct ProcessInfoBuilder {
687 info: ProcessInfo,
688}
689
690impl ProcessInfoBuilder {
691 /// Sets the OS pid; `None` while the sheep is not running.
692 pub fn pid(mut self, pid: Option<u32>) -> Self {
693 self.info.pid = pid;
694 self
695 }
696
697 /// Sets the restart count since registration.
698 pub fn restarts(mut self, restarts: u32) -> Self {
699 self.info.restarts = restarts;
700 self
701 }
702
703 /// Sets milliseconds since the last successful start.
704 pub fn uptime_ms(mut self, uptime_ms: u64) -> Self {
705 self.info.uptime_ms = uptime_ms;
706 self
707 }
708
709 /// Sets fold membership.
710 pub fn fold(mut self, fold: Option<String>) -> Self {
711 self.info.fold = fold;
712 self
713 }
714
715 /// Sets the resolved stdout log path.
716 pub fn out_file(mut self, out_file: Option<String>) -> Self {
717 self.info.out_file = out_file;
718 self
719 }
720
721 /// Sets the resolved stderr log path.
722 pub fn err_file(mut self, err_file: Option<String>) -> Self {
723 self.info.err_file = err_file;
724 self
725 }
726
727 /// Sets tree CPU as a percentage of one core.
728 pub fn cpu_percent(mut self, cpu_percent: Option<f32>) -> Self {
729 self.info.cpu_percent = cpu_percent;
730 self
731 }
732
733 /// Sets tree resident set size in bytes.
734 pub fn memory_bytes(mut self, memory_bytes: Option<u64>) -> Self {
735 self.info.memory_bytes = memory_bytes;
736 self
737 }
738
739 /// Marks this row a dog and names where the dog came from.
740 pub fn dog(mut self, dog: Option<DogSource>) -> Self {
741 self.info.dog = dog;
742 self
743 }
744
745 /// Sets the sheep's lamb list; `None` when this reply did not walk for one.
746 pub fn lambs(mut self, lambs: Option<Vec<Lamb>>) -> Self {
747 self.info.lambs = lambs;
748 self
749 }
750
751 /// Sets how this sheep's process most recently stopped; `None` while it
752 /// has never exited under this daemon.
753 pub fn last_exit(mut self, last_exit: Option<ExitInfo>) -> Self {
754 self.info.last_exit = last_exit;
755 self
756 }
757
758 /// Sets the marker a dog has painted on this sheep; `None` when none has.
759 pub fn smit(mut self, smit: Option<String>) -> Self {
760 self.info.smit = smit;
761 self
762 }
763
764 /// Sets the instance slot; `None` when the peer daemon predates the field.
765 pub fn instance(mut self, instance: Option<u32>) -> Self {
766 self.info.instance = instance;
767 self
768 }
769
770 /// Sets whether this dog has handshaken with the shepherd; `None` for a
771 /// sheep, which has no handshake to report.
772 pub fn handshook(mut self, handshook: Option<bool>) -> Self {
773 self.info.handshook = handshook;
774 self
775 }
776
777 /// Sets whether the shepherd has given up restarting this dog; `None`
778 /// for a sheep, which is never given up on.
779 pub fn dog_stale(mut self, dog_stale: Option<bool>) -> Self {
780 self.info.dog_stale = dog_stale;
781 self
782 }
783
784 /// Sets the field names a load has parked for this sheep's next spawn;
785 /// `None` when nothing is parked.
786 pub fn pending(mut self, pending: Option<Vec<String>>) -> Self {
787 self.info.pending = pending;
788 self
789 }
790
791 /// Sets the field names an operator has overridden on this sheep;
792 /// `None` when there is nothing to report.
793 pub fn overridden(mut self, overridden: Option<Vec<String>>) -> Self {
794 self.info.overridden = overridden;
795 self
796 }
797
798 /// Finishes the row.
799 #[must_use]
800 pub fn build(self) -> ProcessInfo {
801 self.info
802 }
803}
804
805/// What happened when the daemon tried to deliver one sheep's triggered
806/// action.
807// wire format: changing existing variants is a breaking change
808#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
809#[serde(tag = "kind", rename_all = "snake_case")]
810#[non_exhaustive]
811pub enum ActionOutcome {
812 /// The app answered on the shepherd channel.
813 Replied {
814 /// The reply body, exactly as the app sent it.
815 body: String,
816 },
817 /// The sheep had no reachable shepherd channel for the daemon to
818 /// deliver the action over.
819 NoChannel,
820 /// The sheep is a reload drainee, mid-swap and on its way out, so the
821 /// daemon skipped it rather than deliver the action to a process already
822 /// being replaced.
823 Skipped,
824 /// The daemon delivered the action, but no reply arrived before the
825 /// app's configured action timeout elapsed.
826 TimedOut,
827}
828
829/// One matched sheep's row in a `Trigger` reply.
830///
831/// Not a [`ProcessInfo`]: a reply body has nowhere to live on one.
832/// [`Self::outcome`] is per-row, since the selector grammar makes a mixed
833/// flock the normal case.
834// wire format: changing this is a breaking change
835#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
836pub struct ActionReply {
837 /// The sheep's stable id.
838 pub id: u32,
839 /// The sheep's name.
840 pub name: String,
841 /// What happened when the daemon tried to deliver the action.
842 pub outcome: ActionOutcome,
843}
844
845/// What happened when the shepherd tried to deliver one signal.
846// wire format: changing existing variants is a breaking change
847#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
848#[serde(tag = "kind", rename_all = "snake_case")]
849#[non_exhaustive]
850pub enum SignalOutcome {
851 /// The kernel accepted the signal for this sheep's pid.
852 ///
853 /// Says the signal was delivered, not that the app did anything with it.
854 /// A signal the app blocks or ignores is `Delivered` too.
855 Delivered,
856 /// The sheep is registered but has no live process to signal: stopped,
857 /// errored, or waiting out a restart backoff.
858 NotRunning,
859 /// The kernel refused the delivery; carries its reason (`ESRCH` for a
860 /// process reaped between the lookup and the syscall, `EPERM` for one this
861 /// daemon may not signal).
862 Failed {
863 /// The refusal, as the OS worded it.
864 reason: String,
865 },
866}
867
868/// One matched sheep's row in a `Signal` reply.
869///
870/// Per-row like [`ActionReply`]: the selector grammar makes a mixed flock
871/// the normal case.
872// wire format: changing this is a breaking change
873#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
874pub struct SignalReply {
875 /// The sheep's stable id.
876 pub id: u32,
877 /// The sheep's name.
878 pub name: String,
879 /// What happened when the shepherd tried to deliver the signal.
880 pub outcome: SignalOutcome,
881}
882
883/// What happened when the shepherd tried to write one line to a sheep's stdin.
884// wire format: changing existing variants is a breaking change
885#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
886#[serde(tag = "kind", rename_all = "snake_case")]
887#[non_exhaustive]
888pub enum LineOutcome {
889 /// The line was written to the pipe and flushed.
890 ///
891 /// Says the bytes left the shepherd, not that the app read them. A pipe
892 /// holds 64 KiB before it blocks.
893 Sent,
894 /// The sheep has no stdin pipe: its config does not set `stdin = true`, or
895 /// it is not running.
896 ///
897 /// One outcome for two causes: both answer "there is no pipe here".
898 NoStdin,
899 /// The shepherd had a pipe and did not confirm a write to it; carries
900 /// why.
901 ///
902 /// Three shapes reach it: the write failed (the far end is gone), the
903 /// line found the sheep's queue already full, or the write did not
904 /// finish inside the shepherd's own bound. The reason names which.
905 ///
906 /// A timed-out write is not a promise the line was never written: the
907 /// bytes may be part-written into a pipe the app is not draining, and
908 /// land in full the moment it drains. A line still queued behind that one
909 /// is dropped once its caller gives up, so treat a retry as a second
910 /// command.
911 NotWritten {
912 /// What went wrong, in plain English.
913 reason: String,
914 },
915}
916
917/// One matched sheep's row in a `SendLine` reply.
918///
919/// Per-row like [`ActionReply`] and [`SignalReply`].
920// wire format: changing this is a breaking change
921#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
922pub struct LineReply {
923 /// The sheep's stable id.
924 pub id: u32,
925 /// The sheep's name.
926 pub name: String,
927 /// What happened.
928 pub outcome: LineOutcome,
929}
930
931/// A dog's `[dog.<name>]` config section, carried as TOML text.
932///
933/// Travels over the socket rather than the child's environment: a dog's
934/// section routinely holds webhook credentials, and the socket keeps them
935/// out of the process table and out of crash dumps. The manual `Debug`
936/// below prints only a length, since [`Response`] derives `Debug`.
937///
938/// `#[serde(transparent)]`: the wire representation is a bare `String`.
939#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
940#[serde(transparent)]
941pub struct DogSectionToml(String);
942
943impl DogSectionToml {
944 /// The TOML text, empty when the file has no such section.
945 #[must_use]
946 pub fn as_str(&self) -> &str {
947 &self.0
948 }
949}
950
951impl From<String> for DogSectionToml {
952 fn from(toml: String) -> Self {
953 Self(toml)
954 }
955}
956
957impl core::ops::Deref for DogSectionToml {
958 type Target = str;
959
960 fn deref(&self) -> &str {
961 &self.0
962 }
963}
964
965/// Prints a length, never the section body. Pinned as an exact string by
966/// `dog_section_toml_debug_does_not_leak`.
967impl fmt::Debug for DogSectionToml {
968 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
969 write!(f, "DogSectionToml(<{} bytes>)", self.0.len())
970 }
971}
972
973/// One registered sheep whose stored config differs from a caller's copy:
974/// the answer [`Request::ConfigDrift`] is asking for
975///
976/// Field names only, never their values. This is printed at an operator,
977/// and [`AppConfig::env`](crate::config::AppConfig::env) carries secrets,
978/// so a differing `env` reports `"env"` and nothing more. `Debug` is
979/// derived: there is nothing here to redact.
980// wire format: changing field names is a breaking change
981#[non_exhaustive]
982#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
983pub struct SheepDrift {
984 /// The sheep's name. Both configs share it by construction: it is what
985 /// matched them to each other.
986 pub name: String,
987 /// The [`AppConfig`] fields that differ, in field-name order. Never
988 /// empty: a sheep with nothing to report is left out of the answer.
989 pub fields: Vec<String>,
990}
991
992impl SheepDrift {
993 /// Builds one sheep's report.
994 #[must_use]
995 pub fn new(name: impl Into<String>, fields: Vec<String>) -> Self {
996 Self {
997 name: name.into(),
998 fields,
999 }
1000 }
1001}
1002
1003/// What one app's [`Request::ApplyConfig`] did: the answer a load owes the
1004/// operator who ran it
1005///
1006/// One of these per app the request named, found or not and changed or not.
1007///
1008/// [`Self::applied`] and [`Self::pending`] carry field names only, never
1009/// their values, as [`SheepDrift`] does; the merged config never reaches a
1010/// client. [`Self::refused`] is prose and is scoped out of that rule: it
1011/// quotes values out of the file the caller just sent, never out of the
1012/// flock's stored config. `Debug` is derived on that basis: nothing here
1013/// needs redacting.
1014// wire format: changing field names is a breaking change
1015#[non_exhaustive]
1016#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1017pub struct SheepApplied {
1018 /// The sheep's name, exactly as the request spelled it.
1019 pub name: String,
1020 /// Fields now in force, in field-name order. Empty when the load changed
1021 /// nothing the daemon could act on immediately.
1022 pub applied: Vec<String>,
1023 /// Fields the app picks up at its next spawn, in field-name order. Empty
1024 /// when nothing is waiting.
1025 ///
1026 /// `shep reload <name>` promotes them; a client rendering this list says
1027 /// so, since a pending list with no remedy beside it cannot be acted on.
1028 pub pending: Vec<String>,
1029 /// Why some or all of this app's change did not land, in the daemon's own
1030 /// words, or `None` when the whole of it did.
1031 ///
1032 /// Not the same question as the two lists being empty: a refusal raised
1033 /// before anything was touched leaves both empty, and so does a load with
1034 /// nothing to do. It is a sentence rather than a code because the message
1035 /// is what tells them apart.
1036 pub refused: Option<String>,
1037}
1038
1039impl SheepApplied {
1040 /// Builds one app's report.
1041 #[must_use]
1042 pub fn new(
1043 name: impl Into<String>,
1044 applied: Vec<String>,
1045 pending: Vec<String>,
1046 refused: Option<String>,
1047 ) -> Self {
1048 Self {
1049 name: name.into(),
1050 applied,
1051 pending,
1052 refused,
1053 }
1054 }
1055}
1056
1057/// One RPC response (pairs with [`Request`] variants)
1058///
1059/// Ten variants carry a bare `Vec<ProcessInfo>`. Do not collapse them into
1060/// one: each names which request it answers, which is what lets a variant
1061/// diverge without a protocol bump. `Reloading` already means an acceptance
1062/// rather than a result, `Scaled` only the survivors of a scale-down, and
1063/// `Mustered` every sheep of every restored app rather than what this call
1064/// started.
1065// wire format: changing existing variants is a breaking change.
1066// `large_enum_variant` allowed, not fixed: clippy's remedy is to box
1067// `DogStarted`'s payload, a source break for every
1068// `Response::DogStarted(info)` in and out of this workspace.
1069#[allow(clippy::large_enum_variant)]
1070#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1071#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
1072#[non_exhaustive]
1073pub enum Response {
1074 /// Answer to `Ping`
1075 Pong,
1076 /// Answer to `ListFlock`
1077 Flock(Vec<ProcessInfo>),
1078 /// Answer to `Describe`
1079 Described(Vec<ProcessInfo>),
1080 /// Answer to `Start`
1081 Started(Vec<ProcessInfo>),
1082 /// Answer to `Add`: one row per app the request named, registered and
1083 /// spawning nothing.
1084 ///
1085 /// A row here can still be `Online`: `Add` is idempotent by name, so the
1086 /// reply describes the membership the request leaves behind.
1087 Added(Vec<ProcessInfo>),
1088 /// Answer to `ConfigDrift`: one entry per app that is registered under a
1089 /// config different from the one asked about, and no entry for anything
1090 /// else. An empty vector means every app asked about either matches or
1091 /// is not registered at all.
1092 Drifted(Vec<SheepDrift>),
1093 /// Answer to `ApplyConfig`: one entry per app the request named, in the
1094 /// order it named them, the refused and the unchanged included.
1095 ///
1096 /// Complete where [`Self::Drifted`] is filtered: an app missing from
1097 /// "what did you do to each of these" looks like one the daemon dropped.
1098 Applied(Vec<SheepApplied>),
1099 /// Answer to `Stop`
1100 Stopped(Vec<ProcessInfo>),
1101 /// Answer to `Restart`
1102 Restarted(Vec<ProcessInfo>),
1103 /// Answer to `Reload`: an acceptance, not a result.
1104 ///
1105 /// One instance costs a readiness wait plus a drain, so a clustered app
1106 /// outlasts any deadline a client may ask for. The daemon answers as soon
1107 /// as the reload is accepted, with the matched sheep as they stood then,
1108 /// and the swaps report themselves on the bus (`process.reload`,
1109 /// `process.reloaded`, `process.reload_abandoned`). A matched sheep with
1110 /// nothing to replace is listed as the no-op success it is.
1111 Reloading(Vec<ProcessInfo>),
1112 /// Answer to `Scale`: the app's instances that will remain, one row each,
1113 /// ordered by [`sort_flock`]. Every row shares one name, so that is slot
1114 /// order with the id breaking a tie.
1115 ///
1116 /// Scaling down, the departing instances are absent even though their
1117 /// kill ladders are still running; they report themselves on the bus as
1118 /// `process.delete`.
1119 Scaled(Vec<ProcessInfo>),
1120 /// Answer to `SetSmit`: every instance of the named sheep, one row each,
1121 /// carrying the smit as it now stands.
1122 SmitPainted(Vec<ProcessInfo>),
1123 /// Answer to `Delete`: ids removed
1124 Deleted(Vec<u32>),
1125 /// Answer to `Reopen`: every matched sheep, running or not. A sheep with
1126 /// no live log pump has nothing to reopen and is reported as a success,
1127 /// so this carries the same matches `Describe` would.
1128 Reopened(Vec<ProcessInfo>),
1129 /// Answer to `Flush`: one row per matched sheep, running or not, exactly
1130 /// as [`Self::Reopened`].
1131 ///
1132 /// One row per sheep, not per file emptied: several sheep can share one
1133 /// log path, and the daemon truncates each distinct path once.
1134 Flushed(Vec<ProcessInfo>),
1135 /// Answer to `Trigger`: one [`ActionReply`] row per matched sheep, rather
1136 /// than a flock listing, since `ProcessInfo` has nowhere to hold a reply
1137 /// body.
1138 Triggered(Vec<ActionReply>),
1139 /// Answer to `Signal`: one [`SignalReply`] row per matched sheep.
1140 ///
1141 /// Not a flock listing: [`ProcessInfo`] has nowhere to hold a per-sheep
1142 /// outcome.
1143 Signalled(Vec<SignalReply>),
1144 /// Answer to `SendLine`: one [`LineReply`] row per matched sheep.
1145 SentLine(Vec<LineReply>),
1146 /// Answer to `SaveRoll`
1147 RollSaved {
1148 /// Absolute path of the roll the daemon wrote
1149 path: String,
1150 /// How many apps that roll records
1151 apps: u32,
1152 },
1153 /// Answer to `Muster`: every sheep of every app the roll restored, not
1154 /// only the ones this call spawned.
1155 ///
1156 /// Assembling a flock that is already assembled starts nothing, so a
1157 /// listing of what this call spawned would be indistinguishable from an
1158 /// empty roll.
1159 Mustered(Vec<ProcessInfo>),
1160 /// Answer to `DogConfig`: the dog's own section, rendered back to TOML.
1161 ///
1162 /// `toml` is [`DogSectionToml`], whose manual `Debug` keeps the webhook
1163 /// credentials this text carries out of a `{:?}`-formatted `Response`.
1164 DogSection {
1165 /// The `[dog.<name>]` table as TOML text, empty when the file has
1166 /// no such section
1167 toml: DogSectionToml,
1168 },
1169 /// Answer to `EnableDog`: the dog as it stands now
1170 DogStarted(ProcessInfo),
1171 /// Answer to `DogStaleness`: this daemon's own handshake record, split
1172 /// into the dogs it has given up on and the dogs it is still waiting on.
1173 ///
1174 /// Two lists because they answer two questions. `stale` is a finding;
1175 /// `pending` is a reason to ask again, since a reading taken now would
1176 /// be a guess about them.
1177 ///
1178 /// Names only: two builds differing only in the protocol they speak
1179 /// report the same crate version.
1180 DogStaleness {
1181 /// Dogs this daemon has refused twice: once on the handshake that
1182 /// bought them a restart from disk, and again after it. It will not
1183 /// restart them a third time.
1184 stale: Vec<String>,
1185 /// Dogs this daemon is still waiting to hear a final answer from: one
1186 /// whose restart is in flight, or one it supervises that has not
1187 /// handshook yet. Neither stale nor known healthy.
1188 pending: Vec<String>,
1189 },
1190 /// Answer to `HandoverFitness`: `None` when the whole flock can be
1191 /// carried across a daemon handover, and otherwise the sentence saying
1192 /// which sheep cannot be and why.
1193 ///
1194 /// A rendered sentence rather than a structured reason: the set of things
1195 /// a handover cannot carry keeps changing, and the client only prints it.
1196 HandoverFitness {
1197 /// Why the flock cannot be handed over in place, or `None` when it
1198 /// can.
1199 refusal: Option<String>,
1200 },
1201 /// Answer to `Subscribe`
1202 Subscribed,
1203 /// Answer to `KillDaemon`
1204 ShuttingDown,
1205}
1206
1207/// A request frame
1208// wire format: changing this is a breaking change
1209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1210pub struct Envelope {
1211 /// Per-connection request id
1212 pub id: u64,
1213 /// Client-imposed deadline (daemon aborts work past it)
1214 pub deadline_ms: Option<u64>,
1215 /// The request
1216 pub body: Request,
1217}
1218
1219/// A reply frame
1220///
1221/// `result` uses serde's stock `Result` representation: the wire carries
1222/// `{"Ok": ...}` / `{"Err": ...}`, with capitalized keys, pinned by snapshot.
1223// wire format: changing this is a breaking change
1224#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1225pub struct Reply {
1226 /// Echoes [`Envelope::id`]
1227 pub id: u64,
1228 /// The outcome
1229 pub result: Result<Response, RpcError>,
1230}
1231
1232/// Handshake outcome: `HelloAck` or a typed refusal, since version skew is
1233/// an error rather than silence. Same `Ok`/`Err` wire shape as
1234/// [`Reply::result`]; refusals use [`RpcErrorCode::ProtocolMismatch`].
1235pub type HelloReply = Result<HelloAck, RpcError>;
1236
1237/// Structured RPC failure
1238// wire format: changing this is a breaking change
1239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1240pub struct RpcError {
1241 /// Machine-readable code
1242 pub code: RpcErrorCode,
1243 /// Human-readable message (plain English, no theme)
1244 pub message: String,
1245 /// The daemon's own crate version, when it chose to name it.
1246 ///
1247 /// Set on a [`RpcErrorCode::ProtocolMismatch`] refusal, the only place a
1248 /// client can learn it, since [`HelloAck::daemon_version`] never arrives
1249 /// there. `None` on every other error, and on a refusal from a daemon
1250 /// built before the field existed, so a reader treats `None` as unknown
1251 /// and takes the conservative path.
1252 ///
1253 /// Absent on the wire rather than `null`, so
1254 /// [`crate::protocol::PROTOCOL_VERSION`] does not move for it.
1255 #[serde(default, skip_serializing_if = "Option::is_none")]
1256 pub daemon_version: Option<String>,
1257}
1258
1259/// Machine-readable RPC error codes
1260// wire format: changing existing variants is a breaking change
1261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1262#[serde(rename_all = "snake_case")]
1263#[non_exhaustive]
1264pub enum RpcErrorCode {
1265 /// Selector matched nothing
1266 NotFound,
1267 /// Config failed validation daemon-side
1268 InvalidConfig,
1269 /// Spawn failed (exec error, permissions)
1270 SpawnFailed,
1271 /// Handshake protocol version mismatch
1272 ProtocolMismatch,
1273 /// Unexpected daemon-side failure
1274 Internal,
1275 /// The request's deadline expired before the daemon finished it
1276 DeadlineExceeded,
1277}
1278
1279impl RpcErrorCode {
1280 /// Every variant, for code that needs to iterate them all.
1281 ///
1282 /// `#[non_exhaustive]` forces a `_` arm on any match written outside this
1283 /// crate, which would swallow a variant added here and never updated
1284 /// there (`crates/shep-cli/src/exit.rs` maps every code to an exit
1285 /// status).
1286 pub const ALL: [Self; 6] = [
1287 Self::NotFound,
1288 Self::InvalidConfig,
1289 Self::SpawnFailed,
1290 Self::ProtocolMismatch,
1291 Self::Internal,
1292 Self::DeadlineExceeded,
1293 ];
1294
1295 /// Never called; exists so this crate fails to build if a variant is
1296 /// added to [`RpcErrorCode`] without also being added to [`Self::ALL`].
1297 ///
1298 /// A match here is still checked for exhaustiveness, and each arm indexes
1299 /// a fixed literal position into [`Self::ALL`], so growing the enum
1300 /// without growing the array is an out-of-bounds constant index.
1301 #[allow(dead_code)]
1302 const fn assert_all_lists_every_variant(code: Self) -> Self {
1303 match code {
1304 Self::NotFound => Self::ALL[0],
1305 Self::InvalidConfig => Self::ALL[1],
1306 Self::SpawnFailed => Self::ALL[2],
1307 Self::ProtocolMismatch => Self::ALL[3],
1308 Self::Internal => Self::ALL[4],
1309 Self::DeadlineExceeded => Self::ALL[5],
1310 }
1311 }
1312}
1313
1314#[cfg(test)]
1315mod tests {
1316 use super::*;
1317 use crate::config::AppConfig;
1318 use crate::protocol::PROTOCOL_VERSION;
1319 use crate::status::ProcStatus;
1320
1321 fn sample_info() -> ProcessInfo {
1322 ProcessInfo {
1323 id: 3,
1324 name: "web".to_string(),
1325 status: ProcStatus::Online,
1326 pid: Some(4242),
1327 restarts: 1,
1328 uptime_ms: 60_000,
1329 fold: Some("backend".to_string()),
1330 out_file: Some("/home/ada/.shep/logs/web-0-out.log".to_string()),
1331 err_file: Some("/home/ada/.shep/logs/web-0-err.log".to_string()),
1332 // 12.5: an insta JSON snapshot is stable across platforms only
1333 // for a float the binary representation holds exactly.
1334 cpu_percent: Some(12.5),
1335 memory_bytes: Some(48 * 1024 * 1024),
1336 dog: None,
1337 lambs: None,
1338 last_exit: Some(ExitInfo {
1339 code: Some(1),
1340 signal: None,
1341 }),
1342 smit: None,
1343 instance: None,
1344 handshook: None,
1345 dog_stale: None,
1346 // Left at the builder's default: this fixture feeds
1347 // `reply_wire_snapshots` and `bus_event_wire_snapshots`, so a
1348 // `Some(..)` moves pinned bytes.
1349 pending: None,
1350 overridden: None,
1351 }
1352 }
1353
1354 #[test]
1355 fn a_builder_with_nothing_set_is_a_sheep_that_has_not_run() {
1356 let info = ProcessInfo::builder(3, "web", ProcStatus::Stopped).build();
1357
1358 assert_eq!(info.id, 3);
1359 assert_eq!(info.name, "web");
1360 assert_eq!(info.status, ProcStatus::Stopped);
1361 assert_eq!(info.pid, None);
1362 assert_eq!(info.restarts, 0);
1363 assert_eq!(info.uptime_ms, 0);
1364 assert_eq!(info.fold, None);
1365 assert_eq!(info.out_file, None);
1366 assert_eq!(info.err_file, None);
1367 assert_eq!(info.cpu_percent, None);
1368 assert_eq!(info.memory_bytes, None);
1369 assert_eq!(info.dog, None);
1370 assert_eq!(info.lambs, None);
1371 assert_eq!(info.last_exit, None);
1372 }
1373
1374 /// Every field is given a value distinct from every other field's
1375 /// default, so a copy-pasted setter body shows up as a mismatch.
1376 #[test]
1377 fn every_setter_writes_its_own_field_and_no_other() {
1378 let built = ProcessInfo::builder(3, "web", ProcStatus::Online)
1379 .pid(Some(4242))
1380 .restarts(1)
1381 .uptime_ms(60_000)
1382 .fold(Some("backend".to_string()))
1383 .out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
1384 .err_file(Some("/home/ada/.shep/logs/web-0-err.log".to_string()))
1385 .cpu_percent(Some(12.5))
1386 .memory_bytes(Some(48 * 1024 * 1024))
1387 .dog(None)
1388 .last_exit(Some(ExitInfo {
1389 code: Some(1),
1390 signal: None,
1391 }))
1392 .build();
1393
1394 // `sample_info()` is a struct literal on purpose: it is the one
1395 // place that names every field by hand, so this comparison fails the
1396 // day the struct grows a field the builder cannot set.
1397 assert_eq!(built, sample_info());
1398
1399 // `sample_info()`'s `dog` is `None`, the builder's default too, so an
1400 // empty `dog` setter body would pass the comparison above. It cannot
1401 // be changed: it feeds pinned snapshots.
1402 assert_eq!(
1403 ProcessInfo::builder(1, "metrics", ProcStatus::Online)
1404 .dog(Some(DogSource::BuiltIn))
1405 .build()
1406 .dog,
1407 Some(DogSource::BuiltIn),
1408 "an empty `dog` setter body is invisible to the comparison above"
1409 );
1410
1411 // `lambs`, on `dog`'s terms above.
1412 assert_eq!(
1413 ProcessInfo::builder(1, "web", ProcStatus::Online)
1414 .lambs(Some(vec![Lamb::new(4243, "node")]))
1415 .build()
1416 .lambs,
1417 Some(vec![Lamb::new(4243, "node")]),
1418 "an empty `lambs` setter body is invisible to the comparison above"
1419 );
1420
1421 // `smit`, on the same terms, and the field a third party writes: an
1422 // empty setter body drops every dog's mark.
1423 assert_eq!(
1424 ProcessInfo::builder(1, "web", ProcStatus::Online)
1425 .smit(Some("\u{25b2} main@a1b2c3".to_string()))
1426 .build()
1427 .smit
1428 .as_deref(),
1429 Some("\u{25b2} main@a1b2c3"),
1430 "an empty `smit` setter body is invisible to the comparison above"
1431 );
1432
1433 // `handshook`, on the same terms.
1434 assert_eq!(
1435 ProcessInfo::builder(1, "web", ProcStatus::Online)
1436 .handshook(Some(false))
1437 .build()
1438 .handshook,
1439 Some(false),
1440 "an empty `handshook` setter body is invisible to the comparison above"
1441 );
1442
1443 // `dog_stale`, paired with `handshook`: both default to `None`.
1444 assert_eq!(
1445 ProcessInfo::builder(1, "web", ProcStatus::Online)
1446 .dog_stale(Some(true))
1447 .build()
1448 .dog_stale,
1449 Some(true),
1450 "an empty `dog_stale` setter body is invisible to the comparison above"
1451 );
1452
1453 // `pending`, on the same terms.
1454 assert_eq!(
1455 ProcessInfo::builder(1, "web", ProcStatus::Online)
1456 .pending(Some(vec!["env".to_string()]))
1457 .build()
1458 .pending,
1459 Some(vec!["env".to_string()]),
1460 "an empty `pending` setter body is invisible to the comparison above"
1461 );
1462
1463 // `overridden`, on the same terms.
1464 assert_eq!(
1465 ProcessInfo::builder(1, "web", ProcStatus::Online)
1466 .overridden(Some(vec!["cwd".to_string()]))
1467 .build()
1468 .overridden,
1469 Some(vec!["cwd".to_string()]),
1470 "an empty `overridden` setter body is invisible to the comparison above"
1471 );
1472 }
1473
1474 #[test]
1475 fn lambs_distinguishes_not_walked_from_walked_and_empty() {
1476 let not_walked = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
1477 assert_eq!(not_walked.lambs, None);
1478
1479 let walked_empty = ProcessInfo::builder(1, "web", ProcStatus::Online)
1480 .lambs(Some(Vec::new()))
1481 .build();
1482 assert_eq!(walked_empty.lambs, Some(Vec::new()));
1483 }
1484
1485 #[test]
1486 fn a_process_info_without_a_lambs_key_still_deserializes() {
1487 let fixture = r#"{
1488 "id": 3, "name": "web", "status": "online", "pid": 4242,
1489 "restarts": 0, "uptime_ms": 100, "fold": null,
1490 "out_file": null, "err_file": null,
1491 "cpu_percent": null, "memory_bytes": null, "dog": null
1492 }"#;
1493 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1494 assert_eq!(info.lambs, None);
1495 }
1496
1497 /// argv holds credentials (`--password=`, `?token=`) and
1498 /// `shep describe --format json` is output people paste into issues.
1499 #[test]
1500 fn a_lamb_is_a_pid_and_an_executable_name() {
1501 let lamb = Lamb::new(4243, "node");
1502 let json = serde_json::to_string(&lamb).unwrap();
1503 assert_eq!(json, r#"{"pid":4243,"name":"node"}"#);
1504 assert_eq!(serde_json::from_str::<Lamb>(&json).unwrap(), lamb);
1505 }
1506
1507 #[test]
1508 fn a_dog_source_serializes_snake_case_under_its_kind() {
1509 assert_eq!(
1510 serde_json::to_string(&DogSource::BuiltIn).unwrap(),
1511 r#"{"kind":"built_in"}"#
1512 );
1513 let adopted = DogSource::Adopted {
1514 path: "/usr/local/bin/shep-otel".to_string(),
1515 };
1516 let wire = r#"{"kind":"adopted","path":"/usr/local/bin/shep-otel"}"#;
1517 assert_eq!(serde_json::to_string(&adopted).unwrap(), wire);
1518 assert_eq!(serde_json::from_str::<DogSource>(wire).unwrap(), adopted);
1519 }
1520
1521 #[test]
1522 fn v1_process_info_without_a_dog_marker_still_deserializes() {
1523 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}"#;
1524 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1525 assert_eq!(info.dog, None);
1526 }
1527
1528 /// No field here carries `#[serde(default)]`: serde's derive resolves a
1529 /// missing key to `None` for a field whose type is syntactically
1530 /// `Option<...>`.
1531 #[test]
1532 fn a_process_info_without_a_last_exit_key_still_deserializes() {
1533 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}"#;
1534 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1535 assert_eq!(info.last_exit, None);
1536 }
1537
1538 #[test]
1539 fn a_signal_request_and_its_reply_round_trip() {
1540 let request = Request::Signal {
1541 selector: SelectorSpec::Name("web".to_string()),
1542 signal: "SIGHUP".to_string(),
1543 };
1544 let json = serde_json::to_string(&request).unwrap();
1545 assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1546
1547 let reply = Response::Signalled(vec![
1548 SignalReply {
1549 id: 1,
1550 name: "web".to_string(),
1551 outcome: SignalOutcome::Delivered,
1552 },
1553 SignalReply {
1554 id: 2,
1555 name: "web".to_string(),
1556 outcome: SignalOutcome::NotRunning,
1557 },
1558 SignalReply {
1559 id: 3,
1560 name: "api".to_string(),
1561 outcome: SignalOutcome::Failed {
1562 reason: "no such process".to_string(),
1563 },
1564 },
1565 ]);
1566 let json = serde_json::to_string(&reply).unwrap();
1567 assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), reply);
1568 // The three tags, spelled out: a variant renamed in Rust changes
1569 // these strings, compiles clean, and breaks a client matching on them.
1570 assert!(json.contains(r#""kind":"delivered""#), "{json}");
1571 assert!(json.contains(r#""kind":"not_running""#), "{json}");
1572 assert!(json.contains(r#""kind":"failed""#), "{json}");
1573 }
1574
1575 /// `instances` is a per-app number, so `shep stock /web.*/ 4` could mean
1576 /// four each or four total.
1577 #[test]
1578 fn a_scale_request_names_one_app_and_a_count() {
1579 let request = Request::Scale {
1580 name: "web".to_string(),
1581 count: 4,
1582 };
1583 let json = serde_json::to_string(&request).unwrap();
1584 assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1585 assert!(json.contains(r#""kind":"scale""#), "{json}");
1586 assert!(json.contains(r#""name":"web""#), "{json}");
1587 // No `selector` key at all: this verb is not one of the
1588 // selector-taking family.
1589 assert!(!json.contains("selector"), "{json}");
1590 }
1591
1592 #[test]
1593 fn a_scaled_reply_carries_its_own_tag() {
1594 let json = serde_json::to_string(&Response::Scaled(vec![])).unwrap();
1595 assert_eq!(json, r#"{"kind":"scaled","data":[]}"#);
1596 }
1597
1598 /// `Add` and `Start` carry byte-identical payloads and differ by their
1599 /// `kind` alone.
1600 #[test]
1601 fn an_add_request_and_its_reply_round_trip() {
1602 let request = Request::Add {
1603 apps: vec![AppConfig::minimal("web", "./srv")],
1604 };
1605 let json = serde_json::to_string(&request).unwrap();
1606 assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1607 assert!(json.contains(r#""kind":"add""#), "{json}");
1608
1609 let reply = Response::Added(vec![]);
1610 let json = serde_json::to_string(&reply).unwrap();
1611 assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), reply);
1612 assert!(json.contains(r#""kind":"added""#), "{json}");
1613 }
1614
1615 /// `NotWritten`'s reason is the only thing separating "the app is not
1616 /// reading its stdin" from "the pipe broke".
1617 #[test]
1618 fn a_send_line_request_and_its_reply_round_trip() {
1619 let request = Request::SendLine {
1620 selector: SelectorSpec::Name("repl".to_string()),
1621 line: "reload-config".to_string(),
1622 };
1623 let json = serde_json::to_string(&request).unwrap();
1624 assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1625
1626 let reply = Response::SentLine(vec![
1627 LineReply {
1628 id: 1,
1629 name: "repl".to_string(),
1630 outcome: LineOutcome::Sent,
1631 },
1632 LineReply {
1633 id: 2,
1634 name: "web".to_string(),
1635 outcome: LineOutcome::NoStdin,
1636 },
1637 LineReply {
1638 id: 3,
1639 name: "stuck".to_string(),
1640 outcome: LineOutcome::NotWritten {
1641 reason: "the app did not read its stdin within 2s".to_string(),
1642 },
1643 },
1644 ]);
1645 let json = serde_json::to_string(&reply).unwrap();
1646 assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), reply);
1647 assert!(json.contains(r#""kind":"sent""#), "{json}");
1648 assert!(json.contains(r#""kind":"no_stdin""#), "{json}");
1649 assert!(json.contains("did not read its stdin"), "{json}");
1650 }
1651
1652 #[test]
1653 fn a_line_carrying_a_newline_is_still_one_field_on_the_wire() {
1654 let request = Request::SendLine {
1655 selector: SelectorSpec::All,
1656 line: "a\nb".to_string(),
1657 };
1658 let json = serde_json::to_string(&request).unwrap();
1659 // Escaped, not literal: the frame stays one JSON object. Refusing
1660 // it is the daemon's job, not serde's.
1661 assert!(json.contains(r#""line":"a\nb""#), "{json}");
1662 assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1663 }
1664
1665 #[test]
1666 fn request_wire_snapshots() {
1667 let requests = vec![
1668 Envelope {
1669 id: 1,
1670 deadline_ms: Some(5000),
1671 body: Request::Ping,
1672 },
1673 Envelope {
1674 id: 2,
1675 deadline_ms: None,
1676 body: Request::ListFlock,
1677 },
1678 Envelope {
1679 id: 3,
1680 deadline_ms: None,
1681 body: Request::Stop {
1682 selector: SelectorSpec::Name("web".to_string()),
1683 },
1684 },
1685 Envelope {
1686 id: 4,
1687 deadline_ms: None,
1688 body: Request::Start {
1689 apps: vec![AppConfig::minimal("web", "./srv")],
1690 },
1691 },
1692 // `All` rather than a named sheep: the selector `shep reopen`
1693 // sends when given no argument.
1694 Envelope {
1695 id: 5,
1696 deadline_ms: None,
1697 body: Request::Reopen {
1698 selector: SelectorSpec::All,
1699 },
1700 },
1701 // The same selector as the row above, so the two log-plane rows
1702 // differ by their `kind` and by nothing else.
1703 Envelope {
1704 id: 6,
1705 deadline_ms: None,
1706 body: Request::Flush {
1707 selector: SelectorSpec::All,
1708 },
1709 },
1710 // The same selector as the `stop` row: `reload` under `stop`'s tag
1711 // shows up here as two identical objects.
1712 Envelope {
1713 id: 7,
1714 deadline_ms: None,
1715 body: Request::Reload {
1716 selector: SelectorSpec::Name("web".to_string()),
1717 },
1718 },
1719 // `action`/`params` match channel.rs's with-params fixture
1720 // verbatim, so a trigger reads the same at every hop.
1721 Envelope {
1722 id: 8,
1723 deadline_ms: None,
1724 body: Request::Trigger {
1725 selector: SelectorSpec::Name("web".to_string()),
1726 action: "set-log-level".to_string(),
1727 params: Some("debug".to_string()),
1728 },
1729 },
1730 // A fieldless verb: a bare `{"kind":"..."}` with no `selector` key.
1731 Envelope {
1732 id: 9,
1733 deadline_ms: None,
1734 body: Request::SaveRoll,
1735 },
1736 // Paired with the `save_roll` row: they differ by their `kind` alone.
1737 Envelope {
1738 id: 10,
1739 deadline_ms: None,
1740 body: Request::Muster,
1741 },
1742 // The three dog verbs. `enable_dog` and `disable_dog` differ by
1743 // their `kind` and by `source` alone.
1744 Envelope {
1745 id: 11,
1746 deadline_ms: None,
1747 body: Request::DogConfig {
1748 name: "bark".to_string(),
1749 },
1750 },
1751 Envelope {
1752 id: 12,
1753 deadline_ms: None,
1754 body: Request::EnableDog {
1755 name: "metrics".to_string(),
1756 source: DogSource::BuiltIn,
1757 },
1758 },
1759 Envelope {
1760 id: 13,
1761 deadline_ms: None,
1762 body: Request::DisableDog {
1763 name: "metrics".to_string(),
1764 },
1765 },
1766 // `Id`, `Regex` and `Fold` are three newtypes the wire tells apart
1767 // only by their `kind` tag: a `Fold` under `regex`'s tag turns
1768 // `shep restart fold:api` into a regex match.
1769 Envelope {
1770 id: 14,
1771 deadline_ms: None,
1772 body: Request::Describe {
1773 selector: SelectorSpec::Id(7),
1774 },
1775 },
1776 Envelope {
1777 id: 15,
1778 deadline_ms: None,
1779 body: Request::Describe {
1780 selector: SelectorSpec::Regex("^web-".to_string()),
1781 },
1782 },
1783 Envelope {
1784 id: 16,
1785 deadline_ms: None,
1786 body: Request::Describe {
1787 selector: SelectorSpec::Fold("api".to_string()),
1788 },
1789 },
1790 // `SIGHUP` rather than `SIGTERM`: the stop ladder already sends
1791 // TERM, so a TERM fixture could not tell the two frames apart.
1792 Envelope {
1793 id: 17,
1794 deadline_ms: None,
1795 body: Request::Signal {
1796 selector: SelectorSpec::Name("web".to_string()),
1797 signal: "SIGHUP".to_string(),
1798 },
1799 },
1800 // The one verb here whose body has no `selector` key.
1801 Envelope {
1802 id: 18,
1803 deadline_ms: None,
1804 body: Request::Scale {
1805 name: "web".to_string(),
1806 count: 4,
1807 },
1808 },
1809 // The line carries no terminator on the wire, since the shepherd
1810 // appends it.
1811 Envelope {
1812 id: 19,
1813 deadline_ms: None,
1814 body: Request::SendLine {
1815 selector: SelectorSpec::All,
1816 line: "reload-config".to_string(),
1817 },
1818 },
1819 // Both halves of the `Option` are pinned, a paint and a clear, so a
1820 // dog author does not have to guess the clear frame's shape.
1821 Envelope {
1822 id: 20,
1823 deadline_ms: None,
1824 body: Request::SetSmit {
1825 sheep: "web".to_string(),
1826 smit: Some(
1827 "\u{25b2} main@a1b2c3"
1828 .parse()
1829 .expect("the reference smit is valid"),
1830 ),
1831 },
1832 },
1833 Envelope {
1834 id: 21,
1835 deadline_ms: None,
1836 body: Request::SetSmit {
1837 sheep: "web".to_string(),
1838 smit: None,
1839 },
1840 },
1841 // An empty `apps`: `start`'s row already pins the payload type, so
1842 // this row's own are the tag and the key the list travels under.
1843 Envelope {
1844 id: 22,
1845 deadline_ms: None,
1846 body: Request::ConfigDrift { apps: Vec::new() },
1847 },
1848 // The only struct-shaped `SelectorSpec` variant, so the only place
1849 // `"kind":"instance"` and the `slot` key are pinned.
1850 Envelope {
1851 id: 23,
1852 deadline_ms: None,
1853 body: Request::Restart {
1854 selector: SelectorSpec::Instance {
1855 name: "web".to_string(),
1856 slot: 2,
1857 },
1858 },
1859 },
1860 // The one request an older daemon must never be sent: shep-cli
1861 // gates it on the daemon's crate version.
1862 Envelope {
1863 id: 24,
1864 deadline_ms: None,
1865 body: Request::HandoverFitness,
1866 },
1867 // The second request gated on the daemon's crate version.
1868 Envelope {
1869 id: 25,
1870 deadline_ms: None,
1871 body: Request::DogStaleness,
1872 },
1873 // The only request carrying a `DeclaredApp`: a merge keys on what a
1874 // document claimed. `declared_env` is non-empty to show it holds
1875 // env key names and no env value, and `reset` is pinned at a
1876 // non-default depth.
1877 Envelope {
1878 id: 26,
1879 deadline_ms: None,
1880 body: Request::ApplyConfig {
1881 apps: vec![DeclaredApp {
1882 config: AppConfig::minimal("web", "./srv"),
1883 declared: ["name", "script"]
1884 .iter()
1885 .map(|k| (*k).to_string())
1886 .collect(),
1887 declared_env: ["DATABASE_URL"].iter().map(|k| (*k).to_string()).collect(),
1888 }],
1889 reset: ResetDepth::Policy,
1890 },
1891 },
1892 // The same app as the `start` row above: the two differ by their
1893 // `kind` alone, so a mis-tagged `add` shows up as two identical
1894 // objects.
1895 Envelope {
1896 id: 27,
1897 deadline_ms: None,
1898 body: Request::Add {
1899 apps: vec![AppConfig::minimal("web", "./srv")],
1900 },
1901 },
1902 ];
1903 insta::assert_json_snapshot!("request_wire_v3", requests);
1904 }
1905
1906 #[test]
1907 fn reply_wire_snapshots() {
1908 let replies = vec![
1909 Reply {
1910 id: 1,
1911 result: Ok(Response::Pong),
1912 },
1913 Reply {
1914 id: 2,
1915 result: Ok(Response::Flock(vec![sample_info()])),
1916 },
1917 Reply {
1918 id: 3,
1919 result: Err(RpcError {
1920 code: RpcErrorCode::NotFound,
1921 message: "no sheep matches `web`".to_string(),
1922 daemon_version: None,
1923 }),
1924 },
1925 // `ActionReply` is not a `ProcessInfo`. `Replied` is the
1926 // struct-shaped `ActionOutcome` variant and so the one worth
1927 // pinning.
1928 Reply {
1929 id: 4,
1930 result: Ok(Response::Triggered(vec![ActionReply {
1931 id: 3,
1932 name: "web".to_string(),
1933 outcome: ActionOutcome::Replied {
1934 body: "ok".to_string(),
1935 },
1936 }])),
1937 },
1938 // The only struct-shaped `Response` variant; every other one is
1939 // a newtype over a `Vec` or a unit, both proven above.
1940 Reply {
1941 id: 5,
1942 result: Ok(Response::RollSaved {
1943 path: "/home/ada/.shep/flock.json".to_string(),
1944 apps: 2,
1945 }),
1946 },
1947 // The present `dog` marker; `sample_info()` pins the absent one.
1948 // `Adopted` because it is the variant carrying a payload.
1949 Reply {
1950 id: 6,
1951 result: Ok(Response::Flock(vec![ProcessInfo {
1952 id: 7,
1953 name: "otel".to_string(),
1954 dog: Some(DogSource::Adopted {
1955 path: "/usr/local/bin/shep-otel".to_string(),
1956 }),
1957 ..sample_info()
1958 }])),
1959 },
1960 // The section crosses the wire as text, never a typed structure.
1961 Reply {
1962 id: 7,
1963 result: Ok(Response::DogSection {
1964 toml: "port = 9615\n".to_string().into(),
1965 }),
1966 },
1967 // The only `Response` variant carrying a bare `ProcessInfo`
1968 // rather than a `Vec`: `enable` starts exactly one dog.
1969 Reply {
1970 id: 8,
1971 result: Ok(Response::DogStarted(ProcessInfo {
1972 id: 4,
1973 name: "metrics".to_string(),
1974 dog: Some(DogSource::BuiltIn),
1975 ..sample_info()
1976 })),
1977 },
1978 // Each row below carries the smallest body that shows its wire
1979 // shape: the tag is what is being pinned. `Deleted` is a
1980 // `Vec<u32>`; `Subscribed` and `ShuttingDown` carry nothing.
1981 Reply {
1982 id: 9,
1983 result: Ok(Response::Described(vec![])),
1984 },
1985 Reply {
1986 id: 10,
1987 result: Ok(Response::Started(vec![])),
1988 },
1989 Reply {
1990 id: 11,
1991 result: Ok(Response::Stopped(vec![])),
1992 },
1993 Reply {
1994 id: 12,
1995 result: Ok(Response::Restarted(vec![])),
1996 },
1997 Reply {
1998 id: 13,
1999 result: Ok(Response::Reloading(vec![])),
2000 },
2001 Reply {
2002 id: 14,
2003 result: Ok(Response::Deleted(vec![7, 8])),
2004 },
2005 Reply {
2006 id: 15,
2007 result: Ok(Response::Reopened(vec![])),
2008 },
2009 Reply {
2010 id: 16,
2011 result: Ok(Response::Flushed(vec![])),
2012 },
2013 Reply {
2014 id: 17,
2015 result: Ok(Response::Mustered(vec![])),
2016 },
2017 Reply {
2018 id: 18,
2019 result: Ok(Response::Subscribed),
2020 },
2021 Reply {
2022 id: 19,
2023 result: Ok(Response::ShuttingDown),
2024 },
2025 // `Signalled`, mirroring the `Triggered` row: one row per
2026 // `SignalOutcome` variant, so no tag is left unproven.
2027 Reply {
2028 id: 20,
2029 result: Ok(Response::Signalled(vec![
2030 SignalReply {
2031 id: 1,
2032 name: "web".to_string(),
2033 outcome: SignalOutcome::Delivered,
2034 },
2035 SignalReply {
2036 id: 2,
2037 name: "web".to_string(),
2038 outcome: SignalOutcome::NotRunning,
2039 },
2040 SignalReply {
2041 id: 3,
2042 name: "api".to_string(),
2043 outcome: SignalOutcome::Failed {
2044 reason: "no such process".to_string(),
2045 },
2046 },
2047 ])),
2048 },
2049 Reply {
2050 id: 21,
2051 result: Ok(Response::Scaled(vec![sample_info()])),
2052 },
2053 // `SentLine`, mirroring the `Signalled` row: one row per
2054 // `LineOutcome` variant.
2055 Reply {
2056 id: 22,
2057 result: Ok(Response::SentLine(vec![
2058 LineReply {
2059 id: 1,
2060 name: "repl".to_string(),
2061 outcome: LineOutcome::Sent,
2062 },
2063 LineReply {
2064 id: 2,
2065 name: "web".to_string(),
2066 outcome: LineOutcome::NoStdin,
2067 },
2068 LineReply {
2069 id: 3,
2070 name: "stuck".to_string(),
2071 outcome: LineOutcome::NotWritten {
2072 reason: "the app did not read its stdin within 2s".to_string(),
2073 },
2074 },
2075 ])),
2076 },
2077 // A walked lamb tree; every other row pins the `null` shape.
2078 Reply {
2079 id: 23,
2080 result: Ok(Response::Described(vec![
2081 ProcessInfo::builder(3, "web", ProcStatus::Online)
2082 .pid(Some(4242))
2083 .lambs(Some(vec![Lamb::new(4243, "node"), Lamb::new(4244, "sh")]))
2084 .build(),
2085 ])),
2086 },
2087 // The killed-by-signal shape of `last_exit`; every row above pins
2088 // the exited-normally one. `SIGTERM`'s raw number, since this
2089 // crate carries no name for it.
2090 Reply {
2091 id: 24,
2092 result: Ok(Response::Flock(vec![
2093 ProcessInfo::builder(5, "worker", ProcStatus::Stopped)
2094 .restarts(1)
2095 .last_exit(Some(ExitInfo {
2096 code: None,
2097 signal: Some(15),
2098 }))
2099 .build(),
2100 ])),
2101 },
2102 // The one row that pins a smit on the wire; `sample_info()` carries
2103 // none.
2104 Reply {
2105 id: 25,
2106 result: Ok(Response::SmitPainted(vec![
2107 ProcessInfo::builder(3, "web", ProcStatus::Online)
2108 .pid(Some(4242))
2109 .smit(Some("\u{25b2} main@a1b2c3".to_string()))
2110 .build(),
2111 ])),
2112 },
2113 // A sheep drifting in one field and a sheep drifting in several.
2114 // `env` is one of them: the name travels and the value never does.
2115 Reply {
2116 id: 26,
2117 result: Ok(Response::Drifted(vec![
2118 SheepDrift::new("web", vec!["cwd".to_string()]),
2119 SheepDrift::new(
2120 "api",
2121 vec!["args".to_string(), "env".to_string(), "script".to_string()],
2122 ),
2123 ])),
2124 },
2125 // The present shape of `instance`; every row above pins its absence.
2126 Reply {
2127 id: 27,
2128 result: Ok(Response::Flock(vec![
2129 ProcessInfo::builder(9, "web", ProcStatus::Online)
2130 .pid(Some(5150))
2131 .instance(Some(2))
2132 .build(),
2133 ])),
2134 },
2135 // Both shapes of the handover answer; the difference between them
2136 // is a `null`.
2137 Reply {
2138 id: 28,
2139 result: Ok(Response::HandoverFitness { refusal: None }),
2140 },
2141 Reply {
2142 id: 29,
2143 result: Ok(Response::HandoverFitness {
2144 refusal: Some("sheep 'web' has a shepherd channel".to_string()),
2145 }),
2146 },
2147 // Both lists non-empty and different: the two carry the same wire
2148 // shape.
2149 Reply {
2150 id: 30,
2151 result: Ok(Response::DogStaleness {
2152 stale: vec!["metrics".to_string()],
2153 pending: vec!["bark".to_string()],
2154 }),
2155 },
2156 // A dog whose process is up and which has never answered this
2157 // shepherd. `dog_stale: false` is the silence still being waited
2158 // out; the row below is the one it has given up on.
2159 Reply {
2160 id: 31,
2161 result: Ok(Response::Flock(vec![
2162 ProcessInfo::builder(10, "log-rotate", ProcStatus::Online)
2163 .pid(Some(208_341))
2164 .dog(Some(DogSource::Adopted {
2165 path: "/usr/local/bin/shep-log-rotate".to_string(),
2166 }))
2167 .handshook(Some(false))
2168 .dog_stale(Some(false))
2169 .build(),
2170 ])),
2171 },
2172 Reply {
2173 id: 32,
2174 result: Ok(Response::Flock(vec![
2175 ProcessInfo::builder(10, "log-rotate", ProcStatus::Online)
2176 .pid(Some(208_341))
2177 .dog(Some(DogSource::Adopted {
2178 path: "/usr/local/bin/shep-log-rotate".to_string(),
2179 }))
2180 .handshook(Some(false))
2181 .dog_stale(Some(true))
2182 .build(),
2183 ])),
2184 },
2185 // Three entries, one per shape a load produces: applied, pending,
2186 // refused. `env` is a pending name on purpose: the name travels
2187 // and the value never does.
2188 Reply {
2189 id: 32,
2190 result: Ok(Response::Applied(vec![
2191 SheepApplied::new("web", vec!["max_memory".to_string()], Vec::new(), None),
2192 SheepApplied::new(
2193 "api",
2194 Vec::new(),
2195 vec!["args".to_string(), "env".to_string()],
2196 None,
2197 ),
2198 SheepApplied::new(
2199 "worker",
2200 Vec::new(),
2201 Vec::new(),
2202 Some("worker is not registered".to_string()),
2203 ),
2204 ])),
2205 },
2206 // `Added`'s tag, all a fixture can prove for a `Vec<ProcessInfo>`
2207 // variant. Down here because every id in this vector is
2208 // hand-written.
2209 Reply {
2210 id: 33,
2211 result: Ok(Response::Added(vec![])),
2212 },
2213 ];
2214 insta::assert_json_snapshot!("reply_wire_v3", replies);
2215 }
2216
2217 /// Asserts on the JSON, not the struct: a `Vec<String>` cannot say which
2218 /// of the two a string is, so a build carrying a value would typecheck.
2219 #[test]
2220 fn a_sheep_applied_carries_names_and_never_values() {
2221 let applied = SheepApplied::new(
2222 "web",
2223 vec!["cwd".to_string()],
2224 vec!["env".to_string()],
2225 None,
2226 );
2227 let json = serde_json::to_string(&applied).unwrap();
2228 assert!(json.contains("\"env\""), "the NAME travels: {json}");
2229 assert!(
2230 !json.contains("DATABASE_URL"),
2231 "and no value ever does: {json}"
2232 );
2233 }
2234
2235 #[test]
2236 fn a_sheep_applied_debug_prints_the_names_it_was_given() {
2237 let applied = SheepApplied::new("web", vec!["cwd".to_string()], Vec::new(), None);
2238 assert_eq!(
2239 format!("{applied:?}"),
2240 "SheepApplied { name: \"web\", applied: [\"cwd\"], pending: [], refused: None }"
2241 );
2242 }
2243
2244 #[test]
2245 fn a_process_info_without_a_smit_key_still_deserializes() {
2246 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}"#;
2247 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2248 assert_eq!(info.smit, None);
2249 }
2250
2251 /// The fixture is a dog's row, where `None` means "render this as it
2252 /// rendered before the field existed", never "never handshaken".
2253 #[test]
2254 fn a_process_info_without_a_handshook_key_still_deserializes() {
2255 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}"#;
2256 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2257 assert_eq!(info.handshook, None);
2258 assert_eq!(info.dog, Some(DogSource::BuiltIn));
2259 }
2260
2261 /// The fixture carries `handshook: false`, the case that matters: `None`
2262 /// is "no verdict to report", never "it has not given up".
2263 #[test]
2264 fn a_process_info_without_a_dog_stale_key_still_deserializes() {
2265 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}"#;
2266 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2267 assert_eq!(info.dog_stale, None);
2268 assert_eq!(info.handshook, Some(false));
2269 }
2270
2271 /// A dog written in another language speaks this wire directly and never
2272 /// runs `FromStr`.
2273 #[test]
2274 fn a_smit_is_validated_when_it_is_deserialized_not_only_when_parsed() {
2275 for bad in [
2276 r#""\u001b[2Jgone""#.to_string(), // an escape
2277 r#""a\nb""#.to_string(), // a newline
2278 r#""""#.to_string(), // empty
2279 r#"" ""#.to_string(), // whitespace
2280 format!(r#""{}""#, "x".repeat(Smit::MAX_CHARS + 1)), // too long
2281 ] {
2282 assert!(
2283 serde_json::from_str::<Smit>(&bad).is_err(),
2284 "a daemon must refuse this on the wire: {bad}"
2285 );
2286 }
2287 assert!(serde_json::from_str::<Smit>(r#""\u25b2 main@a1b2c3""#).is_ok());
2288 }
2289
2290 /// The hand-written `Deserialize` agrees with the derived `Serialize`
2291 /// only while the serialize side stays transparent.
2292 #[test]
2293 fn a_smit_travels_as_a_bare_string() {
2294 let smit: Smit = "\u{25b2} main@a1b2c3".parse().expect("valid");
2295 let json = serde_json::to_string(&smit).unwrap();
2296 assert_eq!(json, "\"\u{25b2} main@a1b2c3\"");
2297 assert_eq!(serde_json::from_str::<Smit>(&json).unwrap(), smit);
2298 }
2299
2300 #[test]
2301 fn a_smit_is_capped_in_characters_not_bytes() {
2302 let cjk = "\u{7f8a}".repeat(Smit::MAX_CHARS);
2303 assert_eq!(cjk.len(), Smit::MAX_CHARS * 3);
2304 assert!(cjk.parse::<Smit>().is_ok(), "{cjk}");
2305 assert_eq!(
2306 "x".repeat(Smit::MAX_CHARS + 1).parse::<Smit>(),
2307 Err(SmitError::TooLong {
2308 chars: Smit::MAX_CHARS + 1
2309 })
2310 );
2311 }
2312
2313 #[test]
2314 fn a_smit_is_stored_exactly_as_it_arrived() {
2315 let padded: Smit = " main@a1b2c3 ".parse().expect("valid");
2316 assert_eq!(padded.as_str(), " main@a1b2c3 ");
2317 assert_eq!(padded.to_string(), " main@a1b2c3 ");
2318 }
2319
2320 #[test]
2321 fn v1_fixture_still_deserializes() {
2322 // Committed byte fixture from protocol v1. If this breaks, bump
2323 // PROTOCOL_VERSION and record it in the CHANGELOG.
2324 let fixture = r#"{"id":7,"deadline_ms":null,"body":{"kind":"stop","selector":{"kind":"name","value":"web"}}}"#;
2325 let env: Envelope = serde_json::from_str(fixture).unwrap();
2326 assert_eq!(env.id, 7);
2327 assert!(matches!(
2328 env.body,
2329 Request::Stop { selector: SelectorSpec::Name(ref n) } if n == "web"
2330 ));
2331 }
2332
2333 #[test]
2334 fn hello_handshake_shape() {
2335 let hello = Hello {
2336 client_version: "0.1.0".to_string(),
2337 protocol: PROTOCOL_VERSION,
2338 dog_name: None,
2339 };
2340 let json = serde_json::to_string(&hello).unwrap();
2341 assert_eq!(json, r#"{"client_version":"0.1.0","protocol":3}"#);
2342 }
2343
2344 #[test]
2345 fn a_dogs_hello_names_the_dog_and_nothing_elses_does() {
2346 let dog = Hello {
2347 client_version: "0.1.0".to_string(),
2348 protocol: PROTOCOL_VERSION,
2349 dog_name: Some("metrics".to_string()),
2350 };
2351 let json = serde_json::to_string(&dog).unwrap();
2352 assert_eq!(
2353 json,
2354 r#"{"client_version":"0.1.0","protocol":3,"dog_name":"metrics"}"#
2355 );
2356 assert_eq!(serde_json::from_str::<Hello>(&json).unwrap(), dog);
2357 }
2358
2359 /// `Hello` is the version-negotiation frame, so `deny_unknown_fields`
2360 /// here would refuse a newer client before `protocol` is read, leaving
2361 /// neither peer able to report the skew.
2362 #[test]
2363 fn a_hello_without_a_dog_name_still_parses() {
2364 let fixture = r#"{"client_version":"0.1.14","protocol":2}"#;
2365 let hello: Hello = serde_json::from_str(fixture).unwrap();
2366 assert_eq!(hello.protocol, 2);
2367 assert_eq!(hello.dog_name, None);
2368
2369 // The other direction: an older daemon ignores a key it does not
2370 // know. `unknown_to_an_older_daemon` stands in for `dog_name`.
2371 let newer = r#"{"client_version":"9.9.9","protocol":2,"dog_name":"metrics","unknown_to_an_older_daemon":true}"#;
2372 let hello: Hello = serde_json::from_str(newer).unwrap();
2373 assert_eq!(hello.protocol, 2);
2374 assert_eq!(hello.dog_name.as_deref(), Some("metrics"));
2375 }
2376
2377 #[test]
2378 fn hello_reply_carries_typed_skew_error() {
2379 let refusal: HelloReply = Err(RpcError {
2380 code: RpcErrorCode::ProtocolMismatch,
2381 message: "daemon speaks protocol 1, client sent 2".to_string(),
2382 daemon_version: None,
2383 });
2384 let json = serde_json::to_string(&refusal).unwrap();
2385 assert_eq!(
2386 json,
2387 r#"{"Err":{"code":"protocol_mismatch","message":"daemon speaks protocol 1, client sent 2"}}"#
2388 );
2389 let back: HelloReply = serde_json::from_str(&json).unwrap();
2390 assert_eq!(back, refusal);
2391 }
2392
2393 #[test]
2394 fn v1_reply_fixture_still_deserializes() {
2395 // Committed byte fixture, protocol v1.
2396 let ok = r#"{"id":1,"result":{"Ok":{"kind":"pong"}}}"#;
2397 let reply: Reply = serde_json::from_str(ok).unwrap();
2398 assert!(matches!(reply.result, Ok(Response::Pong)));
2399 let err = r#"{"id":2,"result":{"Err":{"code":"not_found","message":"no sheep"}}}"#;
2400 let reply: Reply = serde_json::from_str(err).unwrap();
2401 assert_eq!(reply.result.unwrap_err().code, RpcErrorCode::NotFound);
2402 }
2403
2404 #[test]
2405 fn v1_hello_ack_fixture_still_deserializes() {
2406 let fixture = r#"{"Ok":{"daemon_version":"0.1.0","protocol":1,"pid":4242}}"#;
2407 let ack: HelloReply = serde_json::from_str(fixture).unwrap();
2408 assert_eq!(ack.unwrap().pid, 4242);
2409 }
2410
2411 #[test]
2412 fn v1_process_info_without_stats_still_deserializes() {
2413 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"}"#;
2414 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2415 assert_eq!(info.cpu_percent, None);
2416 assert_eq!(info.memory_bytes, None);
2417 }
2418
2419 #[test]
2420 fn v1_process_info_without_log_paths_still_deserializes() {
2421 // Committed byte fixture from before `out_file`/`err_file` existed.
2422 let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend"}"#;
2423 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2424 assert_eq!(info.id, 3);
2425 assert_eq!(info.out_file, None);
2426 assert_eq!(info.err_file, None);
2427 }
2428
2429 #[test]
2430 fn an_old_client_still_decodes_a_new_process_info() {
2431 // `ProcessInfo` carries no `deny_unknown_fields`, unlike the config
2432 // types in `crate::config`, so extra keys are ignored.
2433 #[derive(Deserialize)]
2434 struct V1ProcessInfo {
2435 id: u32,
2436 fold: Option<String>,
2437 }
2438
2439 let current = serde_json::to_string(&sample_info()).unwrap();
2440 let old: V1ProcessInfo = serde_json::from_str(¤t).unwrap();
2441 assert_eq!(old.id, 3);
2442 assert_eq!(old.fold.as_deref(), Some("backend"));
2443 }
2444
2445 #[test]
2446 fn an_rpc_error_without_a_daemon_version_serializes_exactly_as_before() {
2447 // `skip_serializing_if` is what makes the field free: no
2448 // `"daemon_version":null` key for an older client to ignore.
2449 let plain = RpcError {
2450 code: RpcErrorCode::NotFound,
2451 message: "no sheep".to_string(),
2452 daemon_version: None,
2453 };
2454 assert_eq!(
2455 serde_json::to_string(&plain).unwrap(),
2456 r#"{"code":"not_found","message":"no sheep"}"#
2457 );
2458 }
2459
2460 #[test]
2461 fn a_v1_rpc_error_fixture_deserializes_with_no_daemon_version() {
2462 let fixture =
2463 r#"{"code":"protocol_mismatch","message":"daemon speaks protocol 1, client sent 2"}"#;
2464 let err: RpcError = serde_json::from_str(fixture).unwrap();
2465 assert_eq!(err.code, RpcErrorCode::ProtocolMismatch);
2466 assert_eq!(err.daemon_version, None);
2467 }
2468
2469 #[test]
2470 fn an_old_client_ignores_an_rpc_error_field_it_has_never_seen() {
2471 // `RpcError` carries no `deny_unknown_fields`, so an optional field
2472 // may be added without moving `PROTOCOL_VERSION`.
2473 #[derive(Deserialize)]
2474 struct OldRpcError {
2475 code: RpcErrorCode,
2476 message: String,
2477 }
2478
2479 let current = serde_json::to_string(&RpcError {
2480 code: RpcErrorCode::ProtocolMismatch,
2481 message: "daemon speaks protocol 1, client sent 2".to_string(),
2482 daemon_version: Some("0.1.16".to_string()),
2483 })
2484 .unwrap();
2485 let old: OldRpcError = serde_json::from_str(¤t).expect("must tolerate");
2486 assert_eq!(old.code, RpcErrorCode::ProtocolMismatch);
2487 assert_eq!(old.message, "daemon speaks protocol 1, client sent 2");
2488 }
2489
2490 #[test]
2491 fn deadline_exceeded_code_serializes_snake_case() {
2492 assert_eq!(
2493 serde_json::to_string(&RpcErrorCode::DeadlineExceeded).unwrap(),
2494 "\"deadline_exceeded\""
2495 );
2496 assert_eq!(
2497 serde_json::from_str::<RpcErrorCode>("\"deadline_exceeded\"").unwrap(),
2498 RpcErrorCode::DeadlineExceeded
2499 );
2500 }
2501
2502 #[test]
2503 fn action_outcome_kinds_serialize_snake_case_and_round_trip() {
2504 // The shared snapshots exercise only `Replied`, the struct-shaped
2505 // variant.
2506 let cases = [
2507 (
2508 ActionOutcome::Replied {
2509 body: "pong".to_string(),
2510 },
2511 r#"{"kind":"replied","body":"pong"}"#,
2512 ),
2513 (ActionOutcome::NoChannel, r#"{"kind":"no_channel"}"#),
2514 (ActionOutcome::Skipped, r#"{"kind":"skipped"}"#),
2515 (ActionOutcome::TimedOut, r#"{"kind":"timed_out"}"#),
2516 ];
2517 for (outcome, wire) in cases {
2518 assert_eq!(
2519 serde_json::to_string(&outcome).unwrap(),
2520 wire,
2521 "{outcome:?}"
2522 );
2523 assert_eq!(
2524 serde_json::from_str::<ActionOutcome>(wire).unwrap(),
2525 outcome
2526 );
2527 }
2528 }
2529
2530 #[test]
2531 fn save_roll_serializes_snake_case_with_its_payload_under_data() {
2532 assert_eq!(
2533 serde_json::to_string(&Request::SaveRoll).unwrap(),
2534 r#"{"kind":"save_roll"}"#
2535 );
2536 let reply = Response::RollSaved {
2537 path: "/tmp/flock.json".to_string(),
2538 apps: 3,
2539 };
2540 let wire = r#"{"kind":"roll_saved","data":{"path":"/tmp/flock.json","apps":3}}"#;
2541 assert_eq!(serde_json::to_string(&reply).unwrap(), wire);
2542 assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), reply);
2543 }
2544
2545 /// The listing is empty on purpose: `reply_wire_snapshots` pins the row
2546 /// field by field.
2547 #[test]
2548 fn muster_serializes_snake_case_with_its_listing_under_data() {
2549 assert_eq!(
2550 serde_json::to_string(&Request::Muster).unwrap(),
2551 r#"{"kind":"muster"}"#
2552 );
2553 let reply = Response::Mustered(Vec::new());
2554 let wire = r#"{"kind":"mustered","data":[]}"#;
2555 assert_eq!(serde_json::to_string(&reply).unwrap(), wire);
2556 assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), reply);
2557 }
2558
2559 #[test]
2560 fn the_dog_verbs_serialize_snake_case_with_their_payloads_under_data() {
2561 assert_eq!(
2562 serde_json::to_string(&Request::DogConfig {
2563 name: "bark".to_string()
2564 })
2565 .unwrap(),
2566 r#"{"kind":"dog_config","name":"bark"}"#
2567 );
2568 assert_eq!(
2569 serde_json::to_string(&Request::DisableDog {
2570 name: "bark".to_string()
2571 })
2572 .unwrap(),
2573 r#"{"kind":"disable_dog","name":"bark"}"#
2574 );
2575 let section = Response::DogSection {
2576 toml: "port = 9615\n".to_string().into(),
2577 };
2578 let wire = r#"{"kind":"dog_section","data":{"toml":"port = 9615\n"}}"#;
2579 assert_eq!(serde_json::to_string(§ion).unwrap(), wire);
2580 assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), section);
2581 }
2582
2583 #[test]
2584 fn dog_section_toml_debug_does_not_leak() {
2585 // A dog's section routinely holds webhook credentials. Pinned as an
2586 // exact string, so a `#[derive(Debug)]` on `DogSectionToml` fails
2587 // here.
2588 let toml: DogSectionToml =
2589 "webhook_url = \"https://discord.com/api/webhooks/1/super-secret-token\"\n"
2590 .to_string()
2591 .into();
2592 assert_eq!(format!("{toml:?}"), "DogSectionToml(<70 bytes>)");
2593
2594 let response = Response::DogSection { toml };
2595 assert_eq!(
2596 format!("{response:?}"),
2597 "DogSection { toml: DogSectionToml(<70 bytes>) }"
2598 );
2599 }
2600
2601 /// The fixture cannot agree under either candidate order: by id it is
2602 /// `web/1, api/2, web/0`, by name `api, web, web`. The two `web` rows
2603 /// are the tiebreak half, seeded out of order.
2604 #[test]
2605 fn a_listing_sorts_by_name_then_by_id() {
2606 let mut listing = vec![
2607 ProcessInfo::builder(1, "web", ProcStatus::Online).build(),
2608 ProcessInfo::builder(2, "api", ProcStatus::Online).build(),
2609 ProcessInfo::builder(0, "web", ProcStatus::Online).build(),
2610 ];
2611 sort_flock(&mut listing);
2612
2613 let seen: Vec<(&str, u32)> = listing
2614 .iter()
2615 .map(|info| (info.name.as_str(), info.id))
2616 .collect();
2617 assert_eq!(
2618 seen,
2619 vec![("api", 2), ("web", 0), ("web", 1)],
2620 "name first, then id inside a name"
2621 );
2622 }
2623
2624 #[test]
2625 fn an_instance_slot_survives_a_round_trip_and_defaults_to_absent() {
2626 let with = ProcessInfo::builder(1, "web", ProcStatus::Online)
2627 .instance(Some(2))
2628 .build();
2629 assert_eq!(with.instance, Some(2));
2630
2631 let without = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
2632 assert_eq!(
2633 without.instance, None,
2634 "a row nobody set a slot on says so, rather than claiming slot 0"
2635 );
2636 }
2637
2638 #[test]
2639 fn a_reply_from_a_daemon_without_the_field_deserializes_as_absent() {
2640 let json = r#"{"id":1,"name":"web","status":"online","pid":null,
2641 "restarts":0,"uptime_ms":0,"fold":null,"out_file":null,
2642 "err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":null,
2643 "lambs":null,"last_exit":null,"smit":null}"#;
2644 let info: ProcessInfo = serde_json::from_str(json).expect("older reply still parses");
2645 assert_eq!(info.instance, None);
2646 }
2647
2648 #[test]
2649 fn sort_flock_orders_by_slot_before_id() {
2650 // A reload gave slot 0 a fresh, higher id. Slot order must still win.
2651 let mut listing = vec![
2652 ProcessInfo::builder(9, "web", ProcStatus::Online)
2653 .instance(Some(0))
2654 .build(),
2655 ProcessInfo::builder(2, "web", ProcStatus::Online)
2656 .instance(Some(1))
2657 .build(),
2658 ];
2659 sort_flock(&mut listing);
2660 assert_eq!(
2661 listing.iter().map(|i| i.id).collect::<Vec<_>>(),
2662 vec![9, 2],
2663 "slot 0 leads even though its id is higher"
2664 );
2665 }
2666
2667 #[test]
2668 fn sort_flock_falls_back_to_id_when_no_row_carries_a_slot() {
2669 let mut listing = vec![
2670 ProcessInfo::builder(5, "web", ProcStatus::Online).build(),
2671 ProcessInfo::builder(3, "web", ProcStatus::Online).build(),
2672 ];
2673 sort_flock(&mut listing);
2674 assert_eq!(
2675 listing.iter().map(|i| i.id).collect::<Vec<_>>(),
2676 vec![3, 5],
2677 "an older daemon's listing sorts exactly as it does today"
2678 );
2679 }
2680}