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_v4`.
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 /// One sheep's effective config, for a pane that is about to edit it.
268 ///
269 /// `env` comes back emptied and its key names ride separately, so a
270 /// value never crosses the wire. Read-only: nothing about the sheep
271 /// changes.
272 ///
273 /// Answers [`Response::SheepConfig`], or
274 /// [`RpcErrorCode::NotFound`] when no sheep has that name.
275 SheepConfig {
276 /// The sheep's name, not a selector: a pane edits one sheep, for
277 /// the reason [`Self::Scale`] states at length.
278 name: String,
279 },
280 /// Sets, replaces, or with `None` removes one env key on one sheep,
281 /// recorded as an operator override. Never reads it back.
282 ///
283 /// Its own request rather than a [`Self::ApplyConfig`] depth, because
284 /// no depth does this: `ResetDepth::None` appends only, `File` and
285 /// `Policy` leave env alone, and `Env`/`All` replace the whole map with
286 /// the template's. A pane cannot send the whole map, since it is never
287 /// told the values it would have to send back.
288 ///
289 /// The running child holds the env it was spawned from, so the change
290 /// parks for the next spawn exactly as `ApplyConfig` parks a
291 /// respawn-only field, and `shep reload`/`shep restart` promote it.
292 ///
293 /// Answers [`Response::SheepEnvSet`], or
294 /// [`RpcErrorCode::NotFound`] when no sheep has that name.
295 SetSheepEnv {
296 /// The sheep's name, not a selector, for [`Self::SheepConfig`]'s
297 /// reason.
298 name: String,
299 /// The env key.
300 key: String,
301 /// The value, or `None` to remove the key.
302 ///
303 /// [`EnvValue`], not a bare `String`, for the reason that type's
304 /// own doc gives: this is the most secret-dense field on the wire
305 /// and a derived `Debug` on [`Request`] would print it (IR-41).
306 value: Option<EnvValue>,
307 },
308 /// Sets one config field on one sheep, recorded as an operator
309 /// override.
310 ///
311 /// [`Self::SetSheepEnv`]'s twin for everything that is not `env`, and
312 /// it exists for the reason that one does rather than by symmetry.
313 /// [`Self::ApplyConfig`] can move a single field (one [`DeclaredApp`]
314 /// declaring one key, at [`ResetDepth::File`]), but it moves it as a
315 /// template and spends the operator's override for it. That reasoning
316 /// does not hold here: a pane's value is the operator's, and the sheep
317 /// still differs from its file. Routed through `ApplyConfig`, the `*`
318 /// marker would never appear for that edit.
319 ///
320 /// One field, not a map: a pane edits one row at a time, and a request
321 /// that took several would need [`Response::Applied`]'s per-field
322 /// reporting back again for no caller that wants it.
323 ///
324 /// `env` is refused here and goes through [`Self::SetSheepEnv`]. So are
325 /// `name` and `instances`, which are
326 /// [`ApplyGroup::Structural`](crate::config::ApplyGroup::Structural):
327 /// identity and flock shape rather than runtime knobs, and the count
328 /// moves through [`Self::Scale`].
329 ///
330 /// The four-way apply classification governs exactly as it does for a
331 /// load. A `Live` field is in force at the daemon's next decision, a
332 /// `NextSpawn` field reaches the stored spec, and a `NeedsRespawn`
333 /// field parks for `shep reload` to promote.
334 ///
335 /// Answers [`Response::SheepFieldSet`], or
336 /// [`RpcErrorCode::NotFound`] when no sheep has that name.
337 SetSheepField {
338 /// The sheep's name, not a selector, for [`Self::SheepConfig`]'s
339 /// reason.
340 name: String,
341 /// The [`AppConfig`] field to set. A key that type has no such
342 /// field is refused with [`RpcErrorCode::InvalidConfig`] rather
343 /// than ignored.
344 key: String,
345 /// The new value, in the shape that field serializes as. The daemon
346 /// must re-validate the resulting config (peer input is untrusted)
347 /// and refuses with [`RpcErrorCode::InvalidConfig`] when it does not
348 /// deserialize or does not normalize; nothing is written in either
349 /// case.
350 ///
351 /// A bare [`serde_json::Value`] and not a redacting newtype, unlike
352 /// [`Self::SetSheepEnv`]'s [`EnvValue`], and the asymmetry is
353 /// deliberate. `env` is the one field [`AppConfig`]'s own manual
354 /// `Debug` redacts; `cwd`, `script` and `args` are printed in the
355 /// clear by every request that already carries a whole config
356 /// ([`Self::Start`], [`Self::Add`], [`Self::ApplyConfig`]). A
357 /// newtype here would protect one copy of a value this enum prints
358 /// three other ways, which reads as a guarantee the wire does not
359 /// make. Widening that protection is a change to [`AppConfig`]'s
360 /// `Debug`, not to this field.
361 value: serde_json::Value,
362 },
363 /// Replaces one dog's `[<name>]` section in `dogs.toml` and publishes
364 /// `config.dog.<name>` so a running dog re-reads it.
365 ///
366 /// The writing twin of [`Self::DogConfig`], which reads the same
367 /// section.
368 ///
369 /// Answers [`Response::DogConfigSet`].
370 SetDogConfig {
371 /// The dog's name, the config key.
372 name: String,
373 /// The whole section, as TOML text.
374 ///
375 /// [`DogSectionToml`], not a bare `String`, for the reason that
376 /// type's own doc gives: a section can hold a dog's credentials and
377 /// this is what keeps them out of a `{:?}` (IR-41).
378 toml: DogSectionToml,
379 },
380 /// Stop matching sheep (stay registered)
381 Stop {
382 /// Which sheep
383 selector: SelectorSpec,
384 },
385 /// Restart matching sheep
386 Restart {
387 /// Which sheep
388 selector: SelectorSpec,
389 },
390 /// Replace each matching sheep with a fresh instance of the same app, one
391 /// instance of an app at a time, so the app has a window in which it can
392 /// stay reachable across the swap
393 Reload {
394 /// Which sheep. No default: a reload replaces running processes.
395 selector: SelectorSpec,
396 },
397 /// Stop + deregister matching sheep
398 Delete {
399 /// Which sheep
400 selector: SelectorSpec,
401 },
402 /// Set how many instances one app runs (see `shep stock`).
403 ///
404 /// Takes a name where every other verb takes a [`SelectorSpec`]:
405 /// `instances` is a per-app number and slots are allocated against the
406 /// same-name group, so a selector matching two apps could mean four of
407 /// each or four in total.
408 ///
409 /// The count is absolute: two operators sending `+2` against the same
410 /// app would get a number neither asked for.
411 Scale {
412 /// The app's name, exactly as its config spells it. Not a selector: no
413 /// `all`, no regex, no `fold:`.
414 name: String,
415 /// How many instances the app has when this returns. `0` is refused
416 /// with [`RpcErrorCode::InvalidConfig`]: `shep delete` is the verb
417 /// for removing an app.
418 count: u32,
419 },
420 /// Attach a short marker to `sheep` for `shep flock` to paint, or clear
421 /// it with `None`.
422 ///
423 /// By name, not a selector: a smit belongs to a sheep, and every
424 /// instance of that name shows it, one spawned after the paint included.
425 ///
426 /// Held in memory and scoped to the connection that sent it, so a
427 /// publisher republishes rather than publishing on change.
428 SetSmit {
429 /// Which sheep.
430 sheep: String,
431 /// The marker, or `None` to clear it.
432 smit: Option<Smit>,
433 },
434 /// Reopen every matched sheep's log files, for an external rotator that
435 /// has renamed them (`create`-mode rotation)
436 Reopen {
437 /// Which sheep
438 selector: SelectorSpec,
439 },
440 /// Empty every matched sheep's log files: flush what is still pending,
441 /// then truncate the recorded paths
442 Flush {
443 /// Which sheep. No default: this destroys log data.
444 selector: SelectorSpec,
445 },
446 /// Send a named action to every matched sheep over its shepherd channel
447 /// and report what each app says back (see `shep trigger`).
448 Trigger {
449 /// Which sheep. No default, matching every other verb that reaches
450 /// a running process.
451 selector: SelectorSpec,
452 /// The action name. Free-form: the daemon never declares, parses, or
453 /// validates it, and an app that does not recognize the name is
454 /// expected to say so in its own reply.
455 action: String,
456 /// Argument text, passed through to the app verbatim. One opaque
457 /// string, matching the shepherd channel's own `action` message this
458 /// becomes.
459 params: Option<String>,
460 },
461 /// Deliver one signal to every matched sheep's own process, never its
462 /// process group (see `shep signal`).
463 Signal {
464 /// Which sheep. No default, matching every other verb that reaches
465 /// a running process.
466 selector: SelectorSpec,
467 /// The signal's name, as
468 /// [`OperatorSignal`](crate::signals::OperatorSignal) spells it. The
469 /// `SIG` prefix and the case are both optional; a name outside the
470 /// grammar answers [`RpcErrorCode::InvalidConfig`].
471 signal: String,
472 },
473 /// Write one line to every matched sheep's stdin (see `shep whisper`).
474 SendLine {
475 /// Which sheep. No default, matching every other verb that reaches a
476 /// running process.
477 selector: SelectorSpec,
478 /// The line, without its terminator: the shepherd appends exactly
479 /// one `\n` when it writes.
480 ///
481 /// A line containing an embedded newline is refused
482 /// ([`RpcErrorCode::InvalidConfig`]): it would deliver two commands
483 /// where the operator typed one.
484 line: String,
485 },
486 /// Write the muster roll now, bypassing the snapshot writer's debounce
487 SaveRoll,
488 /// Assemble the flock from the muster roll on disk: start every app the
489 /// roll recorded running, leaving every app the flock already has exactly
490 /// as it stands
491 Muster,
492 /// Ask for one dog's `[dog.<name>]` section, as the dog itself parses it
493 DogConfig {
494 /// The dog's name: the config key, not a selector
495 name: String,
496 },
497 /// Start one dog now, marking it as coming from `source`
498 EnableDog {
499 /// The dog's name
500 name: String,
501 /// Where its binary comes from
502 source: DogSource,
503 },
504 /// Stop and deregister one dog
505 ///
506 /// Answers [`Response::Deleted`]: disabling deregisters exactly as
507 /// `Delete` does.
508 DisableDog {
509 /// The dog's name
510 name: String,
511 },
512 /// Ask which dogs this daemon has given up on, and which it is still
513 /// waiting to hear from (`shep daemon reload`).
514 ///
515 /// Read-only, and about this daemon's own handshakes: take the reading
516 /// after a reload, not before one. Never sent to an older daemon, on
517 /// [`Self::HandoverFitness`]'s terms.
518 ///
519 /// Answers [`Response::DogStaleness`].
520 DogStaleness,
521 /// Ask whether this daemon could hand its flock to a successor in place,
522 /// rather than stopping it and starting it again (`shep daemon reload`).
523 ///
524 /// Read-only: the handover itself is triggered by a signal, which reaches
525 /// a daemon that refuses the client at the handshake.
526 ///
527 /// Answers [`Response::HandoverFitness`]. A refusal is a feature the
528 /// running daemon cannot carry, not an error: the caller falls back to a
529 /// stop-and-start and prints the reason. Never sent to an older daemon:
530 /// shep-cli's `commands::daemon` gates it on the crate version the
531 /// handshake reported.
532 HandoverFitness,
533 /// Graceful daemon shutdown
534 KillDaemon,
535 /// Subscribe this connection to bus topics (glob patterns)
536 Subscribe {
537 /// Topic globs, e.g. `process.*`
538 topics: Vec<String>,
539 },
540}
541
542/// Where a dog came from: this binary, or one an operator adopted.
543///
544/// Carried on [`ProcessInfo::dog`], so a listing distinguishes the two
545/// populations without a second request.
546// wire format: changing existing variants is a breaking change
547#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
548#[serde(tag = "kind", rename_all = "snake_case")]
549#[non_exhaustive]
550pub enum DogSource {
551 /// An argv branch of the shep binary itself (`shep dog <name>`).
552 BuiltIn,
553 /// A binary an operator adopted, run at the daemon's own trust level.
554 Adopted {
555 /// The binary's path, exactly as the operator gave it to `adopt`.
556 path: String,
557 },
558}
559
560/// One process the OS reports as a descendant of a sheep.
561///
562/// Not the set of processes that die with the sheep: this is a parent-pid
563/// walk, where the stop ladder acts on the process group. A lamb that forks
564/// and exits leaves children re-parented to init, out of this list and still
565/// in the group; a `setsid()` grandchild stays in the list and leaves the
566/// group.
567///
568/// `name` is the executable's name (`node`, `sh`), never argv, which carries
569/// credentials and would ride into `shep describe --format json`. Build one
570/// with [`Self::new`].
571// wire format: changing this is a breaking change
572#[non_exhaustive]
573#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
574pub struct Lamb {
575 /// The lamb's own pid.
576 pub pid: u32,
577 /// The executable's name, as the OS reports it. Never its command line.
578 pub name: String,
579}
580
581impl Lamb {
582 /// One lamb.
583 #[must_use]
584 pub fn new(pid: u32, name: impl Into<String>) -> Self {
585 Self {
586 pid,
587 name: name.into(),
588 }
589 }
590}
591
592/// Why a sheep's process most recently stopped existing under this daemon.
593///
594/// Behind [`ProcessInfo::last_exit`]'s own `Option`, so `None` there means
595/// never exited. Ordinarily exactly one of `code`/`signal` is `Some`,
596/// mirroring the OS's `WIFEXITED`/`WIFSIGNALED` split; both `None` together
597/// is legal and means this daemon recorded an exit it could not
598/// characterize.
599// wire format: changing this is a breaking change
600#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
601pub struct ExitInfo {
602 /// The process's own exit code, set on a normal exit (`WIFEXITED`).
603 pub code: Option<i32>,
604 /// The raw unix signal number that ended the process, set when it did
605 /// not exit on its own (`WIFSIGNALED`). An operator's own `shep stop`
606 /// counts: the process still stopped by a signal.
607 ///
608 /// Platform-specific, and never rendered as a name here; that is an
609 /// OS-aware layer's job.
610 pub signal: Option<i32>,
611}
612
613/// Snapshot of one sheep for listings and events
614///
615/// Construct one with [`ProcessInfo::builder`]: `#[non_exhaustive]` forbids
616/// a struct literal outside this crate, though not inside it. The fields
617/// stay `pub`.
618// wire format: changing this is a breaking change. No `Eq`: `cpu_percent` is
619// an `f32`. Paths travel as `String`, since serde's `PathBuf` refuses a
620// non-UTF-8 path and would blank a whole `Reply`. Every added field is an
621// `Option`, so a peer built before it sends no key and `None` reads as unknown.
622#[non_exhaustive]
623#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
624pub struct ProcessInfo {
625 /// Stable numeric id
626 pub id: u32,
627 /// Sheep name
628 pub name: String,
629 /// Lifecycle status
630 pub status: ProcStatus,
631 /// OS pid while running
632 pub pid: Option<u32>,
633 /// Restart count since registration
634 pub restarts: u32,
635 /// Milliseconds since last successful start
636 pub uptime_ms: u64,
637 /// Fold membership
638 pub fold: Option<String>,
639 /// Resolved stdout log path: the app's explicit
640 /// [`AppConfig::out_file`] when it set one, else the daemon-derived
641 /// default. `None` only when the peer daemon predates this field.
642 pub out_file: Option<String>,
643 /// Resolved stderr log path, resolved exactly as [`Self::out_file`]
644 pub err_file: Option<String>,
645 /// Tree CPU as a percentage of one core, over the window since the
646 /// daemon's last periodic sample. `None` when the sheep is not running,
647 /// when it has been up for less than one sampling window, or when the
648 /// peer daemon predates the field; all three render as unknown, never as
649 /// zero. A value over 100 is a tree using more than one core.
650 pub cpu_percent: Option<f32>,
651 /// Tree resident set size in bytes, current as of the reply. `None`
652 /// under the same three conditions as [`Self::cpu_percent`], minus the
653 /// window one: memory needs no baseline.
654 pub memory_bytes: Option<u64>,
655 /// Set when this entry is a dog, naming where the dog came from;
656 /// `None` for a sheep.
657 ///
658 /// Two cases, not [`Self::cpu_percent`]'s three: "not a dog" is the true
659 /// answer whether the peer predates the field or the entry is a sheep.
660 pub dog: Option<DogSource>,
661 /// The processes the OS reports as descendants of this sheep, or `None`
662 /// when this reply did not walk for them.
663 ///
664 /// `None` covers two cases: this reply is not a `Describe` (only
665 /// `Describe` walks), or the peer daemon predates the field.
666 /// `Some(vec![])` is the third: walked, and this sheep has no children.
667 ///
668 /// Read [`Lamb`]'s own doc before rendering this: the list is not the set
669 /// of processes a stop kills, and any output built from it has to say so.
670 pub lambs: Option<Vec<Lamb>>,
671 /// How this sheep's process most recently stopped existing under this
672 /// daemon. `None` while it has never exited under this daemon, and when
673 /// the peer daemon predates the field.
674 ///
675 /// Sticky across a respawn: it answers why the sheep last stopped, not
676 /// whether it is stopped now, and updates only on the next exit.
677 pub last_exit: Option<ExitInfo>,
678 /// The marker a dog has asked to have painted beside this sheep, or
679 /// `None` when no dog has painted one, which also covers a peer daemon
680 /// that predates the field.
681 ///
682 /// A `String` rather than a [`Smit`]: this is a report, and the
683 /// validation that makes it safe to print happened at the daemon's
684 /// ingress. Every instance of a name shows the same marker, since smits
685 /// are keyed by sheep name.
686 pub smit: Option<String>,
687 /// Which instance slot of its app this sheep occupies, counting from 0.
688 ///
689 /// `None` when the peer daemon predates the field. Not a bare `u32`
690 /// defaulted to 0: an app stocked to four instances would report four
691 /// rows all claiming slot 0.
692 pub instance: Option<u32>,
693 /// Whether this dog has completed a handshake with the shepherd that is
694 /// reporting it, and not been refused since; `None` for a sheep.
695 ///
696 /// `None` on [`Self::dog`]'s two-case terms: a sheep has no connection
697 /// to the shepherd, so "no handshake fact to report" is the true answer
698 /// for a sheep and for a peer that predates the field alike.
699 ///
700 /// `Some(false)` is the one that matters: a dog on a protocol this
701 /// shepherd refuses is alive, which is all [`Self::status`] reports, and
702 /// not doing its job. A fact and not a verdict, though: a dog spawned a
703 /// moment ago has not handshaken yet and is healthy.
704 pub handshook: Option<bool>,
705 /// Whether the reporting shepherd has given up on this dog: restarted it
706 /// once for never answering, watched that not help, and stopped
707 /// restarting it. `None` for a sheep, on [`Self::handshook`]'s terms.
708 ///
709 /// Not derivable from [`Self::handshook`]. `Some(false)` there covers
710 /// both a dog spawned three seconds ago that has not dialled back and one
711 /// this shepherd has permanently stopped restarting; the first needs
712 /// nothing done and the second is an incident.
713 ///
714 /// A fact and not a verdict: it says the shepherd stopped, never why.
715 /// The why is in that dog's own log (`shep bleats <dog>`).
716 pub dog_stale: Option<bool>,
717 /// The [`AppConfig`] field names this sheep's spec differs from a load's
718 /// parked config for, in field-name order. `None` when nothing is parked,
719 /// and when the peer daemon predates the field.
720 ///
721 /// Names only, never values, as [`SheepDrift::fields`] carries them: a
722 /// differing `env` reports `"env"` and stops there. `shep reload`
723 /// promotes a parked config.
724 #[serde(default, skip_serializing_if = "Option::is_none")]
725 pub pending: Option<Vec<String>>,
726 /// The [`AppConfig`] field names an operator has set on this sheep that
727 /// its current Flockfile does not declare, in field-name order. `None`
728 /// when there is nothing to report, and when the peer daemon predates
729 /// the field.
730 ///
731 /// Names only, never values, for [`Self::pending`]'s reason:
732 /// [`crate::overrides::AppOverrides::fields`] can hold an `env` value.
733 #[serde(default, skip_serializing_if = "Option::is_none")]
734 pub overridden: Option<Vec<String>>,
735 /// The sheep's `max_memory` ceiling in bytes, when it has one.
736 ///
737 /// Additive, like [`Self::instance`] and [`Self::handshook`] before it, so
738 /// neither `PROTOCOL_VERSION` nor `SCHEMA_VERSION` moves: an older payload
739 /// decodes with it absent and an older client ignores it. Lookout's
740 /// `MEM/CEIL` gauge is the only reader; `None` draws an all-tail bar
741 /// rather than guessing a denominator.
742 pub max_memory: Option<u64>,
743}
744
745/// Orders one flock listing the way every operator-facing surface presents
746/// one: `(name, instance, id)`.
747///
748/// Name first: an id is assigned at registration and a `delete all` plus a
749/// fresh start renumbers the flock, where a name survives. A name is not a
750/// total order on its own, so the id breaks the tie and stays an addressing
751/// key (`shep stop 11`).
752///
753/// A listing whose rows all carry `None` for the slot collapses to
754/// `(name, id)`, since `None` sorts before every `Some`.
755pub fn sort_flock(listing: &mut [ProcessInfo]) {
756 listing.sort_unstable_by(|a, b| {
757 (a.name.as_str(), a.instance, a.id).cmp(&(b.name.as_str(), b.instance, b.id))
758 });
759}
760
761impl ProcessInfo {
762 /// Starts a builder for one sheep's row.
763 ///
764 /// The three arguments are the fields no row can omit and no reader can
765 /// default.
766 ///
767 /// No `#[must_use]`: [`ProcessInfoBuilder`] carries one, which clippy's
768 /// `double_must_use` lint treats as covering this return too.
769 pub fn builder(id: u32, name: impl Into<String>, status: ProcStatus) -> ProcessInfoBuilder {
770 ProcessInfoBuilder {
771 info: Self {
772 id,
773 name: name.into(),
774 status,
775 pid: None,
776 restarts: 0,
777 uptime_ms: 0,
778 fold: None,
779 out_file: None,
780 err_file: None,
781 cpu_percent: None,
782 memory_bytes: None,
783 dog: None,
784 lambs: None,
785 last_exit: None,
786 smit: None,
787 instance: None,
788 handshook: None,
789 dog_stale: None,
790 pending: None,
791 overridden: None,
792 max_memory: None,
793 },
794 }
795 }
796}
797
798/// Builds a [`ProcessInfo`], which is `#[non_exhaustive]` and so cannot be
799/// written as a struct literal outside this crate.
800///
801/// Every setter takes the field's own type, `Option` included, so a caller
802/// already holding `Option<u32>` writes `.pid(entry.pid())` rather than an
803/// `if let` ladder. A setter is skipped, not passed `None`, when a row has
804/// nothing to say about that field; the skipped defaults are the ones a
805/// not-yet-running sheep has.
806#[derive(Debug, Clone)]
807#[must_use = "a builder that is never `build`-ed produces no ProcessInfo"]
808pub struct ProcessInfoBuilder {
809 info: ProcessInfo,
810}
811
812impl ProcessInfoBuilder {
813 /// Sets the OS pid; `None` while the sheep is not running.
814 pub fn pid(mut self, pid: Option<u32>) -> Self {
815 self.info.pid = pid;
816 self
817 }
818
819 /// Sets the restart count since registration.
820 pub fn restarts(mut self, restarts: u32) -> Self {
821 self.info.restarts = restarts;
822 self
823 }
824
825 /// Sets milliseconds since the last successful start.
826 pub fn uptime_ms(mut self, uptime_ms: u64) -> Self {
827 self.info.uptime_ms = uptime_ms;
828 self
829 }
830
831 /// Sets fold membership.
832 pub fn fold(mut self, fold: Option<String>) -> Self {
833 self.info.fold = fold;
834 self
835 }
836
837 /// Sets the resolved stdout log path.
838 pub fn out_file(mut self, out_file: Option<String>) -> Self {
839 self.info.out_file = out_file;
840 self
841 }
842
843 /// Sets the resolved stderr log path.
844 pub fn err_file(mut self, err_file: Option<String>) -> Self {
845 self.info.err_file = err_file;
846 self
847 }
848
849 /// Sets tree CPU as a percentage of one core.
850 pub fn cpu_percent(mut self, cpu_percent: Option<f32>) -> Self {
851 self.info.cpu_percent = cpu_percent;
852 self
853 }
854
855 /// Sets tree resident set size in bytes.
856 pub fn memory_bytes(mut self, memory_bytes: Option<u64>) -> Self {
857 self.info.memory_bytes = memory_bytes;
858 self
859 }
860
861 /// Marks this row a dog and names where the dog came from.
862 pub fn dog(mut self, dog: Option<DogSource>) -> Self {
863 self.info.dog = dog;
864 self
865 }
866
867 /// Sets the sheep's lamb list; `None` when this reply did not walk for one.
868 pub fn lambs(mut self, lambs: Option<Vec<Lamb>>) -> Self {
869 self.info.lambs = lambs;
870 self
871 }
872
873 /// Sets how this sheep's process most recently stopped; `None` while it
874 /// has never exited under this daemon.
875 pub fn last_exit(mut self, last_exit: Option<ExitInfo>) -> Self {
876 self.info.last_exit = last_exit;
877 self
878 }
879
880 /// Sets the marker a dog has painted on this sheep; `None` when none has.
881 pub fn smit(mut self, smit: Option<String>) -> Self {
882 self.info.smit = smit;
883 self
884 }
885
886 /// Sets the instance slot; `None` when the peer daemon predates the field.
887 pub fn instance(mut self, instance: Option<u32>) -> Self {
888 self.info.instance = instance;
889 self
890 }
891
892 /// Sets whether this dog has handshaken with the shepherd; `None` for a
893 /// sheep, which has no handshake to report.
894 pub fn handshook(mut self, handshook: Option<bool>) -> Self {
895 self.info.handshook = handshook;
896 self
897 }
898
899 /// Sets whether the shepherd has given up restarting this dog; `None`
900 /// for a sheep, which is never given up on.
901 pub fn dog_stale(mut self, dog_stale: Option<bool>) -> Self {
902 self.info.dog_stale = dog_stale;
903 self
904 }
905
906 /// Sets the field names a load has parked for this sheep's next spawn;
907 /// `None` when nothing is parked.
908 pub fn pending(mut self, pending: Option<Vec<String>>) -> Self {
909 self.info.pending = pending;
910 self
911 }
912
913 /// Sets the field names an operator has overridden on this sheep;
914 /// `None` when there is nothing to report.
915 pub fn overridden(mut self, overridden: Option<Vec<String>>) -> Self {
916 self.info.overridden = overridden;
917 self
918 }
919
920 /// Sets the sheep's `max_memory` ceiling in bytes; `None` when it has no
921 /// ceiling configured.
922 pub fn max_memory(mut self, max_memory: Option<u64>) -> Self {
923 self.info.max_memory = max_memory;
924 self
925 }
926
927 /// Finishes the row.
928 #[must_use]
929 pub fn build(self) -> ProcessInfo {
930 self.info
931 }
932}
933
934/// What happened when the daemon tried to deliver one sheep's triggered
935/// action.
936// wire format: changing existing variants is a breaking change
937#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
938#[serde(tag = "kind", rename_all = "snake_case")]
939#[non_exhaustive]
940pub enum ActionOutcome {
941 /// The app answered on the shepherd channel.
942 Replied {
943 /// The reply body, exactly as the app sent it.
944 body: String,
945 },
946 /// The sheep had no reachable shepherd channel for the daemon to
947 /// deliver the action over.
948 NoChannel,
949 /// The sheep is a reload drainee, mid-swap and on its way out, so the
950 /// daemon skipped it rather than deliver the action to a process already
951 /// being replaced.
952 Skipped,
953 /// The daemon delivered the action, but no reply arrived before the
954 /// app's configured action timeout elapsed.
955 TimedOut,
956}
957
958/// One matched sheep's row in a `Trigger` reply.
959///
960/// Not a [`ProcessInfo`]: a reply body has nowhere to live on one.
961/// [`Self::outcome`] is per-row, since the selector grammar makes a mixed
962/// flock the normal case.
963// wire format: changing this is a breaking change
964#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
965pub struct ActionReply {
966 /// The sheep's stable id.
967 pub id: u32,
968 /// The sheep's name.
969 pub name: String,
970 /// What happened when the daemon tried to deliver the action.
971 pub outcome: ActionOutcome,
972}
973
974/// What happened when the shepherd tried to deliver one signal.
975// wire format: changing existing variants is a breaking change
976#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
977#[serde(tag = "kind", rename_all = "snake_case")]
978#[non_exhaustive]
979pub enum SignalOutcome {
980 /// The kernel accepted the signal for this sheep's pid.
981 ///
982 /// Says the signal was delivered, not that the app did anything with it.
983 /// A signal the app blocks or ignores is `Delivered` too.
984 Delivered,
985 /// The sheep is registered but has no live process to signal: stopped,
986 /// errored, or waiting out a restart backoff.
987 NotRunning,
988 /// The kernel refused the delivery; carries its reason (`ESRCH` for a
989 /// process reaped between the lookup and the syscall, `EPERM` for one this
990 /// daemon may not signal).
991 Failed {
992 /// The refusal, as the OS worded it.
993 reason: String,
994 },
995}
996
997/// One matched sheep's row in a `Signal` reply.
998///
999/// Per-row like [`ActionReply`]: the selector grammar makes a mixed flock
1000/// the normal case.
1001// wire format: changing this is a breaking change
1002#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1003pub struct SignalReply {
1004 /// The sheep's stable id.
1005 pub id: u32,
1006 /// The sheep's name.
1007 pub name: String,
1008 /// What happened when the shepherd tried to deliver the signal.
1009 pub outcome: SignalOutcome,
1010}
1011
1012/// What happened when the shepherd tried to write one line to a sheep's stdin.
1013// wire format: changing existing variants is a breaking change
1014#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1015#[serde(tag = "kind", rename_all = "snake_case")]
1016#[non_exhaustive]
1017pub enum LineOutcome {
1018 /// The line was written to the pipe and flushed.
1019 ///
1020 /// Says the bytes left the shepherd, not that the app read them. A pipe
1021 /// holds 64 KiB before it blocks.
1022 Sent,
1023 /// The sheep has no stdin pipe: its config does not set `stdin = true`, or
1024 /// it is not running.
1025 ///
1026 /// One outcome for two causes: both answer "there is no pipe here".
1027 NoStdin,
1028 /// The shepherd had a pipe and did not confirm a write to it; carries
1029 /// why.
1030 ///
1031 /// Three shapes reach it: the write failed (the far end is gone), the
1032 /// line found the sheep's queue already full, or the write did not
1033 /// finish inside the shepherd's own bound. The reason names which.
1034 ///
1035 /// A timed-out write is not a promise the line was never written: the
1036 /// bytes may be part-written into a pipe the app is not draining, and
1037 /// land in full the moment it drains. A line still queued behind that one
1038 /// is dropped once its caller gives up, so treat a retry as a second
1039 /// command.
1040 NotWritten {
1041 /// What went wrong, in plain English.
1042 reason: String,
1043 },
1044}
1045
1046/// One matched sheep's row in a `SendLine` reply.
1047///
1048/// Per-row like [`ActionReply`] and [`SignalReply`].
1049// wire format: changing this is a breaking change
1050#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1051pub struct LineReply {
1052 /// The sheep's stable id.
1053 pub id: u32,
1054 /// The sheep's name.
1055 pub name: String,
1056 /// What happened.
1057 pub outcome: LineOutcome,
1058}
1059
1060/// A dog's `[dog.<name>]` config section, carried as TOML text.
1061///
1062/// Travels over the socket rather than the child's environment: a dog's
1063/// section routinely holds webhook credentials, and the socket keeps them
1064/// out of the process table and out of crash dumps. The manual `Debug`
1065/// below prints only a length, since [`Response`] derives `Debug`.
1066///
1067/// [`Self::as_str`] is the only way out: a `Deref<Target = str>` would hand
1068/// the type `ToString` and defeat that `Debug`.
1069///
1070/// `#[serde(transparent)]`: the wire representation is a bare `String`.
1071#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
1072#[serde(transparent)]
1073pub struct DogSectionToml(String);
1074
1075impl DogSectionToml {
1076 /// The TOML text, empty when the file has no such section.
1077 #[must_use]
1078 pub fn as_str(&self) -> &str {
1079 &self.0
1080 }
1081}
1082
1083impl From<String> for DogSectionToml {
1084 fn from(toml: String) -> Self {
1085 Self(toml)
1086 }
1087}
1088
1089/// Prints a length, never the section body. Pinned as an exact string by
1090/// `dog_section_toml_debug_does_not_leak`.
1091impl fmt::Debug for DogSectionToml {
1092 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1093 write!(f, "DogSectionToml(<{} bytes>)", self.0.len())
1094 }
1095}
1096
1097/// One environment variable's value, on its way to a sheep.
1098///
1099/// A newtype for one reason, the same one [`DogSectionToml`] exists for: an
1100/// env value is the single most secret-dense thing a client can send this
1101/// daemon (a database URL, an API token, a signing key), and a derived
1102/// `Debug` on [`Request`] would print it in the clear the moment anything
1103/// logs a request. Every other secret-bearing field on this wire is already
1104/// protected by its inner type ([`AppConfig`]'s own manual `Debug` prints
1105/// `env: <N vars>`), and a bare `String` here would have been the first
1106/// field in the enum without that protection.
1107///
1108/// One direction only. Nothing ever sends one back: [`Request::SheepConfig`]
1109/// answers with the env keys and no values at all.
1110///
1111/// [`Self::as_str`] is the only way out, for the reason
1112/// [`DogSectionToml`] gives: a `Deref<Target = str>` would hand the type
1113/// `ToString` too, and `.to_string()` would return the value in the clear,
1114/// defeating the redacted `Debug` below.
1115///
1116/// `#[serde(transparent)]` makes the wire representation identical to a
1117/// bare `String`, so this newtype changes nothing about
1118/// [`crate::protocol::PROTOCOL_VERSION`] or the pinned snapshot fixtures.
1119#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
1120#[serde(transparent)]
1121pub struct EnvValue(String);
1122
1123impl EnvValue {
1124 /// The value.
1125 #[must_use]
1126 pub fn as_str(&self) -> &str {
1127 &self.0
1128 }
1129}
1130
1131impl From<String> for EnvValue {
1132 fn from(value: String) -> Self {
1133 Self(value)
1134 }
1135}
1136
1137/// Debug prints a length and never the value (IR-41); see the type doc for
1138/// why. Exact-string-tested below (`env_value_debug_does_not_leak`) so a
1139/// future `#[derive(Debug)]` fails that test instead of silently reopening
1140/// the leak.
1141impl fmt::Debug for EnvValue {
1142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1143 write!(f, "EnvValue(<{} bytes>)", self.0.len())
1144 }
1145}
1146
1147/// One registered sheep whose stored config differs from a caller's copy:
1148/// the answer [`Request::ConfigDrift`] is asking for
1149///
1150/// Field names only, never their values. This is printed at an operator,
1151/// and [`AppConfig::env`](crate::config::AppConfig::env) carries secrets,
1152/// so a differing `env` reports `"env"` and nothing more. `Debug` is
1153/// derived: there is nothing here to redact.
1154// wire format: changing field names is a breaking change
1155#[non_exhaustive]
1156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1157pub struct SheepDrift {
1158 /// The sheep's name. Both configs share it by construction: it is what
1159 /// matched them to each other.
1160 pub name: String,
1161 /// The [`AppConfig`] fields that differ, in field-name order. Never
1162 /// empty: a sheep with nothing to report is left out of the answer.
1163 pub fields: Vec<String>,
1164}
1165
1166impl SheepDrift {
1167 /// Builds one sheep's report.
1168 #[must_use]
1169 pub fn new(name: impl Into<String>, fields: Vec<String>) -> Self {
1170 Self {
1171 name: name.into(),
1172 fields,
1173 }
1174 }
1175}
1176
1177/// What one app's [`Request::ApplyConfig`] did: the answer a load owes the
1178/// operator who ran it
1179///
1180/// One of these per app the request named, found or not and changed or not.
1181///
1182/// [`Self::applied`] and [`Self::pending`] carry field names only, never
1183/// their values, as [`SheepDrift`] does; the merged config never reaches a
1184/// client. [`Self::refused`] is prose and is scoped out of that rule: it
1185/// quotes values out of the file the caller just sent, never out of the
1186/// flock's stored config. `Debug` is derived on that basis: nothing here
1187/// needs redacting.
1188// wire format: changing field names is a breaking change
1189#[non_exhaustive]
1190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1191pub struct SheepApplied {
1192 /// The sheep's name, exactly as the request spelled it.
1193 pub name: String,
1194 /// Fields now in force, in field-name order. Empty when the load changed
1195 /// nothing the daemon could act on immediately.
1196 pub applied: Vec<String>,
1197 /// Fields the app picks up at its next spawn, in field-name order. Empty
1198 /// when nothing is waiting.
1199 ///
1200 /// `shep reload <name>` promotes them; a client rendering this list says
1201 /// so, since a pending list with no remedy beside it cannot be acted on.
1202 pub pending: Vec<String>,
1203 /// Why some or all of this app's change did not land, in the daemon's own
1204 /// words, or `None` when the whole of it did.
1205 ///
1206 /// Not the same question as the two lists being empty: a refusal raised
1207 /// before anything was touched leaves both empty, and so does a load with
1208 /// nothing to do. It is a sentence rather than a code because the message
1209 /// is what tells them apart.
1210 pub refused: Option<String>,
1211}
1212
1213impl SheepApplied {
1214 /// Builds one app's report.
1215 #[must_use]
1216 pub fn new(
1217 name: impl Into<String>,
1218 applied: Vec<String>,
1219 pending: Vec<String>,
1220 refused: Option<String>,
1221 ) -> Self {
1222 Self {
1223 name: name.into(),
1224 applied,
1225 pending,
1226 refused,
1227 }
1228 }
1229}
1230
1231/// One sheep's effective config as a pane sees it: every field but env's
1232/// values, plus which fields an operator has overridden and which are
1233/// waiting on a respawn.
1234///
1235/// The answer to [`Request::SheepConfig`], and the one reply in this module
1236/// that carries a whole [`AppConfig`]. [`SheepApplied`] deliberately carries
1237/// field names alone, and the difference is what each is for: that one is
1238/// printed at an operator who already has the file, this one feeds a pane
1239/// that is about to edit fields it has to be able to show first.
1240// wire format: changing field names is a breaking change
1241//
1242// `#[non_exhaustive]`: shep-core is a published library and a sixth field
1243// would otherwise break an out-of-tree consumer's construction of this with
1244// no version bump to say so (IR-20). [`SheepConfigView::new`] is how the
1245// daemon builds one, and it is what enforces the emptied `env`.
1246#[non_exhaustive]
1247#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
1248pub struct SheepConfigView {
1249 /// The sheep's name.
1250 pub name: String,
1251 /// The effective config with `env` cleared. Every remaining field is
1252 /// operator-supplied policy the pane is about to let them edit, so
1253 /// withholding a value would make the pane unusable while protecting
1254 /// nothing.
1255 pub config: AppConfig,
1256 /// The env keys, so the pane can list them. Never the values.
1257 pub env_keys: Vec<String>,
1258 /// Field names an operator has set that the Flockfile does not declare.
1259 pub overridden: Vec<String>,
1260 /// Field names parked until the next respawn.
1261 pub pending: Vec<String>,
1262}
1263
1264impl SheepConfigView {
1265 /// Builds one, clearing `env` and recording its keys.
1266 ///
1267 /// The clearing happens here rather than at the one call site, so a
1268 /// second caller cannot forget it: this constructor is the only way to
1269 /// build the type outside this crate, since `#[non_exhaustive]` blocks
1270 /// a literal.
1271 #[must_use]
1272 pub fn new(mut config: AppConfig, overridden: Vec<String>, pending: Vec<String>) -> Self {
1273 let env_keys = config.env.keys().cloned().collect();
1274 config.env.clear();
1275 Self {
1276 name: config.name.clone(),
1277 config,
1278 env_keys,
1279 overridden,
1280 pending,
1281 }
1282 }
1283}
1284
1285/// Redacted (IR-41): `config` carries `args` and `cwd`, which routinely hold
1286/// a token or a home directory, and this type is what a `{:?}` on a
1287/// [`Response`] would print. The three lists are counted rather than named
1288/// for the same reason: `env_keys` is a key set, which is itself worth
1289/// keeping out of a log.
1290impl fmt::Debug for SheepConfigView {
1291 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1292 write!(
1293 f,
1294 "SheepConfigView {{ name: {:?}, env_keys: {}, overridden: {}, pending: {} }}",
1295 self.name,
1296 self.env_keys.len(),
1297 self.overridden.len(),
1298 self.pending.len()
1299 )
1300 }
1301}
1302
1303/// One RPC response (pairs with [`Request`] variants)
1304///
1305/// Ten variants carry a bare `Vec<ProcessInfo>`. Do not collapse them into
1306/// one: each names which request it answers, which is what lets a variant
1307/// diverge without a protocol bump. `Reloading` already means an acceptance
1308/// rather than a result, `Scaled` only the survivors of a scale-down, and
1309/// `Mustered` every sheep of every restored app rather than what this call
1310/// started.
1311// wire format: changing existing variants is a breaking change.
1312// `large_enum_variant` allowed, not fixed: clippy's remedy is to box
1313// `DogStarted`'s payload, a source break for every
1314// `Response::DogStarted(info)` in and out of this workspace.
1315#[allow(clippy::large_enum_variant)]
1316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1317#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
1318#[non_exhaustive]
1319pub enum Response {
1320 /// Answer to `Ping`
1321 Pong,
1322 /// Answer to `ListFlock`
1323 Flock(Vec<ProcessInfo>),
1324 /// Answer to `Describe`
1325 Described(Vec<ProcessInfo>),
1326 /// Answer to `Start`
1327 Started(Vec<ProcessInfo>),
1328 /// Answer to `Add`: one row per app the request named, registered and
1329 /// spawning nothing.
1330 ///
1331 /// A row here can still be `Online`: `Add` is idempotent by name, so the
1332 /// reply describes the membership the request leaves behind.
1333 Added(Vec<ProcessInfo>),
1334 /// Answer to `ConfigDrift`: one entry per app that is registered under a
1335 /// config different from the one asked about, and no entry for anything
1336 /// else. An empty vector means every app asked about either matches or
1337 /// is not registered at all.
1338 Drifted(Vec<SheepDrift>),
1339 /// Answer to `ApplyConfig`: one entry per app the request named, in the
1340 /// order it named them, the refused and the unchanged included.
1341 ///
1342 /// Complete where [`Self::Drifted`] is filtered: an app missing from
1343 /// "what did you do to each of these" looks like one the daemon dropped.
1344 Applied(Vec<SheepApplied>),
1345 /// Answer to `SheepConfig`: one sheep's config with `env` emptied and
1346 /// its keys listed beside it.
1347 ///
1348 /// Boxed, and the only variant here that is. This one carries a whole
1349 /// [`AppConfig`], which is several times the size of anything else in
1350 /// the enum, and a `Response` is inside a `Reply` which is inside a
1351 /// [`ServerFrame`](crate::protocol::ServerFrame): without the box,
1352 /// every frame the daemon sends costs the largest config's worth of
1353 /// stack for a variant almost none of them use.
1354 ///
1355 /// The enum-level `#[allow(clippy::large_enum_variant)]` below does not
1356 /// cover it, and the difference is the point of that allow's own
1357 /// argument: boxing `DogStarted` would be a source break for every
1358 /// `Response::DogStarted(info)` in and out of this workspace, where
1359 /// this variant has never shipped and so breaks nobody.
1360 ///
1361 /// `Box<T>` serializes exactly as `T`, so the wire bytes and the pinned
1362 /// fixtures are untouched.
1363 SheepConfig(Box<SheepConfigView>),
1364 /// Answer to `SetSheepEnv`: the key that was set or removed.
1365 ///
1366 /// Never the value, and never the resulting env map. This reply exists
1367 /// to confirm which key moved, and echoing what was just written back
1368 /// down a socket would undo the whole point of `SheepConfig` withholding
1369 /// it (IR-41).
1370 SheepEnvSet {
1371 /// The sheep.
1372 name: String,
1373 /// The key.
1374 key: String,
1375 },
1376 /// Answer to `SetSheepField`: which field moved, and whether the
1377 /// running child has it.
1378 ///
1379 /// Not [`Self::Applied`]'s three lists; the difference is the
1380 /// request's own shape. `applied`, `pending` and `refused` exist
1381 /// because `ApplyConfig` carries N apps of M fields, so a caller cannot
1382 /// otherwise tell which field went where or that one app of eleven was
1383 /// refused. This request carries one field of one sheep, so `refused`
1384 /// would be a second way to say no beside the `Err` arm (a client
1385 /// checking only the `Err` would silently swallow the other), and the
1386 /// two lists collapse to the one bit that is left.
1387 ///
1388 /// That bit is not redundant with the field's own
1389 /// [`ApplyGroup`](crate::config::ApplyGroup), which the caller already
1390 /// knows. It is the daemon's answer about state a caller cannot see:
1391 /// `autostart` is `NextSpawn` and yet reports as in force, because it
1392 /// is read at muster rather than at a spawn, and a `Live` field whose
1393 /// config subset will not normalize on its own parks instead of
1394 /// applying.
1395 SheepFieldSet {
1396 /// The sheep.
1397 name: String,
1398 /// The field that moved.
1399 key: String,
1400 /// `true` when the running child does not have the value yet and
1401 /// `shep reload <name>` is what promotes it. A client rendering
1402 /// this says so, the same rule [`SheepApplied::pending`] carries.
1403 pending: bool,
1404 },
1405 /// Answer to `SetDogConfig`: the section was written and the topic
1406 /// published.
1407 DogConfigSet {
1408 /// The dog.
1409 name: String,
1410 },
1411 /// Answer to `Stop`
1412 Stopped(Vec<ProcessInfo>),
1413 /// Answer to `Restart`
1414 Restarted(Vec<ProcessInfo>),
1415 /// Answer to `Reload`: an acceptance, not a result.
1416 ///
1417 /// One instance costs a readiness wait plus a drain, so a clustered app
1418 /// outlasts any deadline a client may ask for. The daemon answers as soon
1419 /// as the reload is accepted, with the matched sheep as they stood then,
1420 /// and the swaps report themselves on the bus (`process.reload`,
1421 /// `process.reloaded`, `process.reload_abandoned`). A matched sheep with
1422 /// nothing to replace is listed as the no-op success it is.
1423 Reloading(Vec<ProcessInfo>),
1424 /// Answer to `Scale`: the app's instances that will remain, one row each,
1425 /// ordered by [`sort_flock`]. Every row shares one name, so that is slot
1426 /// order with the id breaking a tie.
1427 ///
1428 /// Scaling down, the departing instances are absent even though their
1429 /// kill ladders are still running; they report themselves on the bus as
1430 /// `process.delete`.
1431 Scaled(Vec<ProcessInfo>),
1432 /// Answer to `SetSmit`: every instance of the named sheep, one row each,
1433 /// carrying the smit as it now stands.
1434 SmitPainted(Vec<ProcessInfo>),
1435 /// Answer to `Delete`: ids removed
1436 Deleted(Vec<u32>),
1437 /// Answer to `Reopen`: every matched sheep, running or not. A sheep with
1438 /// no live log pump has nothing to reopen and is reported as a success,
1439 /// so this carries the same matches `Describe` would.
1440 Reopened(Vec<ProcessInfo>),
1441 /// Answer to `Flush`: one row per matched sheep, running or not, exactly
1442 /// as [`Self::Reopened`].
1443 ///
1444 /// One row per sheep, not per file emptied: several sheep can share one
1445 /// log path, and the daemon truncates each distinct path once.
1446 Flushed(Vec<ProcessInfo>),
1447 /// Answer to `Trigger`: one [`ActionReply`] row per matched sheep, rather
1448 /// than a flock listing, since `ProcessInfo` has nowhere to hold a reply
1449 /// body.
1450 Triggered(Vec<ActionReply>),
1451 /// Answer to `Signal`: one [`SignalReply`] row per matched sheep.
1452 ///
1453 /// Not a flock listing: [`ProcessInfo`] has nowhere to hold a per-sheep
1454 /// outcome.
1455 Signalled(Vec<SignalReply>),
1456 /// Answer to `SendLine`: one [`LineReply`] row per matched sheep.
1457 SentLine(Vec<LineReply>),
1458 /// Answer to `SaveRoll`
1459 RollSaved {
1460 /// Absolute path of the roll the daemon wrote
1461 path: String,
1462 /// How many apps that roll records
1463 apps: u32,
1464 },
1465 /// Answer to `Muster`: every sheep of every app the roll restored, not
1466 /// only the ones this call spawned.
1467 ///
1468 /// Assembling a flock that is already assembled starts nothing, so a
1469 /// listing of what this call spawned would be indistinguishable from an
1470 /// empty roll.
1471 Mustered(Vec<ProcessInfo>),
1472 /// Answer to `DogConfig`: the dog's own section, rendered back to TOML.
1473 ///
1474 /// `toml` is [`DogSectionToml`], whose manual `Debug` keeps the webhook
1475 /// credentials this text carries out of a `{:?}`-formatted `Response`.
1476 DogSection {
1477 /// The `[dog.<name>]` table as TOML text, empty when the file has
1478 /// no such section
1479 toml: DogSectionToml,
1480 },
1481 /// Answer to `EnableDog`: the dog as it stands now
1482 DogStarted(ProcessInfo),
1483 /// Answer to `DogStaleness`: this daemon's own handshake record, split
1484 /// into the dogs it has given up on and the dogs it is still waiting on.
1485 ///
1486 /// Two lists because they answer two questions. `stale` is a finding;
1487 /// `pending` is a reason to ask again, since a reading taken now would
1488 /// be a guess about them.
1489 ///
1490 /// Names only: two builds differing only in the protocol they speak
1491 /// report the same crate version.
1492 DogStaleness {
1493 /// Dogs this daemon has refused twice: once on the handshake that
1494 /// bought them a restart from disk, and again after it. It will not
1495 /// restart them a third time.
1496 stale: Vec<String>,
1497 /// Dogs this daemon is still waiting to hear a final answer from: one
1498 /// whose restart is in flight, or one it supervises that has not
1499 /// handshook yet. Neither stale nor known healthy.
1500 pending: Vec<String>,
1501 },
1502 /// Answer to `HandoverFitness`: `None` when the whole flock can be
1503 /// carried across a daemon handover, and otherwise the sentence saying
1504 /// which sheep cannot be and why.
1505 ///
1506 /// A rendered sentence rather than a structured reason: the set of things
1507 /// a handover cannot carry keeps changing, and the client only prints it.
1508 HandoverFitness {
1509 /// Why the flock cannot be handed over in place, or `None` when it
1510 /// can.
1511 refusal: Option<String>,
1512 },
1513 /// Answer to `Subscribe`
1514 Subscribed,
1515 /// Answer to `KillDaemon`
1516 ShuttingDown,
1517}
1518
1519/// A request frame
1520// wire format: changing this is a breaking change
1521#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1522pub struct Envelope {
1523 /// Per-connection request id
1524 pub id: u64,
1525 /// Client-imposed deadline (daemon aborts work past it)
1526 pub deadline_ms: Option<u64>,
1527 /// The request
1528 pub body: Request,
1529}
1530
1531/// A reply frame
1532///
1533/// `result` uses serde's stock `Result` representation: the wire carries
1534/// `{"Ok": ...}` / `{"Err": ...}`, with capitalized keys, pinned by snapshot.
1535// wire format: changing this is a breaking change
1536#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1537pub struct Reply {
1538 /// Echoes [`Envelope::id`]
1539 pub id: u64,
1540 /// The outcome
1541 pub result: Result<Response, RpcError>,
1542}
1543
1544/// Handshake outcome: `HelloAck` or a typed refusal, since version skew is
1545/// an error rather than silence. Same `Ok`/`Err` wire shape as
1546/// [`Reply::result`]; refusals use [`RpcErrorCode::ProtocolMismatch`].
1547pub type HelloReply = Result<HelloAck, RpcError>;
1548
1549/// Structured RPC failure
1550// wire format: changing this is a breaking change
1551#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1552pub struct RpcError {
1553 /// Machine-readable code
1554 pub code: RpcErrorCode,
1555 /// Human-readable message (plain English, no theme)
1556 pub message: String,
1557 /// The daemon's own crate version, when it chose to name it.
1558 ///
1559 /// Set on a [`RpcErrorCode::ProtocolMismatch`] refusal, the only place a
1560 /// client can learn it, since [`HelloAck::daemon_version`] never arrives
1561 /// there. `None` on every other error, and on a refusal from a daemon
1562 /// built before the field existed, so a reader treats `None` as unknown
1563 /// and takes the conservative path.
1564 ///
1565 /// Absent on the wire rather than `null`, so
1566 /// [`crate::protocol::PROTOCOL_VERSION`] does not move for it.
1567 #[serde(default, skip_serializing_if = "Option::is_none")]
1568 pub daemon_version: Option<String>,
1569}
1570
1571/// Machine-readable RPC error codes
1572// wire format: changing existing variants is a breaking change
1573#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1574#[serde(rename_all = "snake_case")]
1575#[non_exhaustive]
1576pub enum RpcErrorCode {
1577 /// Selector matched nothing
1578 NotFound,
1579 /// Config failed validation daemon-side
1580 InvalidConfig,
1581 /// Spawn failed (exec error, permissions)
1582 SpawnFailed,
1583 /// Handshake protocol version mismatch
1584 ProtocolMismatch,
1585 /// Unexpected daemon-side failure
1586 Internal,
1587 /// The request's deadline expired before the daemon finished it
1588 DeadlineExceeded,
1589}
1590
1591impl RpcErrorCode {
1592 /// Every variant, for code that needs to iterate them all.
1593 ///
1594 /// `#[non_exhaustive]` forces a `_` arm on any match written outside this
1595 /// crate, which would swallow a variant added here and never updated
1596 /// there (`crates/shep-cli/src/exit.rs` maps every code to an exit
1597 /// status).
1598 pub const ALL: [Self; 6] = [
1599 Self::NotFound,
1600 Self::InvalidConfig,
1601 Self::SpawnFailed,
1602 Self::ProtocolMismatch,
1603 Self::Internal,
1604 Self::DeadlineExceeded,
1605 ];
1606
1607 /// Never called; exists so this crate fails to build if a variant is
1608 /// added to [`RpcErrorCode`] without also being added to [`Self::ALL`].
1609 ///
1610 /// A match here is still checked for exhaustiveness, and each arm indexes
1611 /// a fixed literal position into [`Self::ALL`], so growing the enum
1612 /// without growing the array is an out-of-bounds constant index.
1613 #[allow(dead_code)]
1614 const fn assert_all_lists_every_variant(code: Self) -> Self {
1615 match code {
1616 Self::NotFound => Self::ALL[0],
1617 Self::InvalidConfig => Self::ALL[1],
1618 Self::SpawnFailed => Self::ALL[2],
1619 Self::ProtocolMismatch => Self::ALL[3],
1620 Self::Internal => Self::ALL[4],
1621 Self::DeadlineExceeded => Self::ALL[5],
1622 }
1623 }
1624}
1625
1626#[cfg(test)]
1627mod tests {
1628 use super::*;
1629 use crate::config::AppConfig;
1630 use crate::protocol::PROTOCOL_VERSION;
1631 use crate::status::ProcStatus;
1632
1633 fn sample_info() -> ProcessInfo {
1634 ProcessInfo {
1635 id: 3,
1636 name: "web".to_string(),
1637 status: ProcStatus::Online,
1638 pid: Some(4242),
1639 restarts: 1,
1640 uptime_ms: 60_000,
1641 fold: Some("backend".to_string()),
1642 out_file: Some("/home/ada/.shep/logs/web-0-out.log".to_string()),
1643 err_file: Some("/home/ada/.shep/logs/web-0-err.log".to_string()),
1644 // 12.5: an insta JSON snapshot is stable across platforms only
1645 // for a float the binary representation holds exactly.
1646 cpu_percent: Some(12.5),
1647 memory_bytes: Some(48 * 1024 * 1024),
1648 dog: None,
1649 lambs: None,
1650 last_exit: Some(ExitInfo {
1651 code: Some(1),
1652 signal: None,
1653 }),
1654 smit: None,
1655 instance: None,
1656 handshook: None,
1657 dog_stale: None,
1658 // Left at the builder's default: this fixture feeds
1659 // `reply_wire_snapshots` and `bus_event_wire_snapshots`, so a
1660 // `Some(..)` moves pinned bytes.
1661 pending: None,
1662 overridden: None,
1663 max_memory: Some(512 * 1024 * 1024),
1664 }
1665 }
1666
1667 #[test]
1668 fn a_builder_with_nothing_set_is_a_sheep_that_has_not_run() {
1669 let info = ProcessInfo::builder(3, "web", ProcStatus::Stopped).build();
1670
1671 assert_eq!(info.id, 3);
1672 assert_eq!(info.name, "web");
1673 assert_eq!(info.status, ProcStatus::Stopped);
1674 assert_eq!(info.pid, None);
1675 assert_eq!(info.restarts, 0);
1676 assert_eq!(info.uptime_ms, 0);
1677 assert_eq!(info.fold, None);
1678 assert_eq!(info.out_file, None);
1679 assert_eq!(info.err_file, None);
1680 assert_eq!(info.cpu_percent, None);
1681 assert_eq!(info.memory_bytes, None);
1682 assert_eq!(info.dog, None);
1683 assert_eq!(info.lambs, None);
1684 assert_eq!(info.last_exit, None);
1685 }
1686
1687 /// Every field is given a value distinct from every other field's
1688 /// default, so a copy-pasted setter body shows up as a mismatch.
1689 #[test]
1690 fn every_setter_writes_its_own_field_and_no_other() {
1691 let built = ProcessInfo::builder(3, "web", ProcStatus::Online)
1692 .pid(Some(4242))
1693 .restarts(1)
1694 .uptime_ms(60_000)
1695 .fold(Some("backend".to_string()))
1696 .out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
1697 .err_file(Some("/home/ada/.shep/logs/web-0-err.log".to_string()))
1698 .cpu_percent(Some(12.5))
1699 .memory_bytes(Some(48 * 1024 * 1024))
1700 .dog(None)
1701 .last_exit(Some(ExitInfo {
1702 code: Some(1),
1703 signal: None,
1704 }))
1705 .max_memory(Some(512 * 1024 * 1024))
1706 .build();
1707
1708 // `sample_info()` is a struct literal on purpose: it is the one
1709 // place that names every field by hand, so this comparison fails the
1710 // day the struct grows a field the builder cannot set.
1711 assert_eq!(built, sample_info());
1712
1713 // `sample_info()`'s `dog` is `None`, the builder's default too, so an
1714 // empty `dog` setter body would pass the comparison above. It cannot
1715 // be changed: it feeds pinned snapshots.
1716 assert_eq!(
1717 ProcessInfo::builder(1, "metrics", ProcStatus::Online)
1718 .dog(Some(DogSource::BuiltIn))
1719 .build()
1720 .dog,
1721 Some(DogSource::BuiltIn),
1722 "an empty `dog` setter body is invisible to the comparison above"
1723 );
1724
1725 // `lambs`, on `dog`'s terms above.
1726 assert_eq!(
1727 ProcessInfo::builder(1, "web", ProcStatus::Online)
1728 .lambs(Some(vec![Lamb::new(4243, "node")]))
1729 .build()
1730 .lambs,
1731 Some(vec![Lamb::new(4243, "node")]),
1732 "an empty `lambs` setter body is invisible to the comparison above"
1733 );
1734
1735 // `smit`, on the same terms, and the field a third party writes: an
1736 // empty setter body drops every dog's mark.
1737 assert_eq!(
1738 ProcessInfo::builder(1, "web", ProcStatus::Online)
1739 .smit(Some("\u{25b2} main@a1b2c3".to_string()))
1740 .build()
1741 .smit
1742 .as_deref(),
1743 Some("\u{25b2} main@a1b2c3"),
1744 "an empty `smit` setter body is invisible to the comparison above"
1745 );
1746
1747 // `handshook`, on the same terms.
1748 assert_eq!(
1749 ProcessInfo::builder(1, "web", ProcStatus::Online)
1750 .handshook(Some(false))
1751 .build()
1752 .handshook,
1753 Some(false),
1754 "an empty `handshook` setter body is invisible to the comparison above"
1755 );
1756
1757 // `dog_stale`, paired with `handshook`: both default to `None`.
1758 assert_eq!(
1759 ProcessInfo::builder(1, "web", ProcStatus::Online)
1760 .dog_stale(Some(true))
1761 .build()
1762 .dog_stale,
1763 Some(true),
1764 "an empty `dog_stale` setter body is invisible to the comparison above"
1765 );
1766
1767 // `pending`, on the same terms.
1768 assert_eq!(
1769 ProcessInfo::builder(1, "web", ProcStatus::Online)
1770 .pending(Some(vec!["env".to_string()]))
1771 .build()
1772 .pending,
1773 Some(vec!["env".to_string()]),
1774 "an empty `pending` setter body is invisible to the comparison above"
1775 );
1776
1777 // `overridden`, on the same terms.
1778 assert_eq!(
1779 ProcessInfo::builder(1, "web", ProcStatus::Online)
1780 .overridden(Some(vec!["cwd".to_string()]))
1781 .build()
1782 .overridden,
1783 Some(vec!["cwd".to_string()]),
1784 "an empty `overridden` setter body is invisible to the comparison above"
1785 );
1786 }
1787
1788 #[test]
1789 fn lambs_distinguishes_not_walked_from_walked_and_empty() {
1790 let not_walked = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
1791 assert_eq!(not_walked.lambs, None);
1792
1793 let walked_empty = ProcessInfo::builder(1, "web", ProcStatus::Online)
1794 .lambs(Some(Vec::new()))
1795 .build();
1796 assert_eq!(walked_empty.lambs, Some(Vec::new()));
1797 }
1798
1799 #[test]
1800 fn a_process_info_without_a_lambs_key_still_deserializes() {
1801 let fixture = r#"{
1802 "id": 3, "name": "web", "status": "online", "pid": 4242,
1803 "restarts": 0, "uptime_ms": 100, "fold": null,
1804 "out_file": null, "err_file": null,
1805 "cpu_percent": null, "memory_bytes": null, "dog": null
1806 }"#;
1807 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1808 assert_eq!(info.lambs, None);
1809 }
1810
1811 /// argv holds credentials (`--password=`, `?token=`) and
1812 /// `shep describe --format json` is output people paste into issues.
1813 #[test]
1814 fn a_lamb_is_a_pid_and_an_executable_name() {
1815 let lamb = Lamb::new(4243, "node");
1816 let json = serde_json::to_string(&lamb).unwrap();
1817 assert_eq!(json, r#"{"pid":4243,"name":"node"}"#);
1818 assert_eq!(serde_json::from_str::<Lamb>(&json).unwrap(), lamb);
1819 }
1820
1821 #[test]
1822 fn a_dog_source_serializes_snake_case_under_its_kind() {
1823 assert_eq!(
1824 serde_json::to_string(&DogSource::BuiltIn).unwrap(),
1825 r#"{"kind":"built_in"}"#
1826 );
1827 let adopted = DogSource::Adopted {
1828 path: "/usr/local/bin/shep-otel".to_string(),
1829 };
1830 let wire = r#"{"kind":"adopted","path":"/usr/local/bin/shep-otel"}"#;
1831 assert_eq!(serde_json::to_string(&adopted).unwrap(), wire);
1832 assert_eq!(serde_json::from_str::<DogSource>(wire).unwrap(), adopted);
1833 }
1834
1835 #[test]
1836 fn v1_process_info_without_a_dog_marker_still_deserializes() {
1837 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}"#;
1838 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1839 assert_eq!(info.dog, None);
1840 }
1841
1842 /// No field here carries `#[serde(default)]`: serde's derive resolves a
1843 /// missing key to `None` for a field whose type is syntactically
1844 /// `Option<...>`.
1845 #[test]
1846 fn a_process_info_without_a_last_exit_key_still_deserializes() {
1847 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}"#;
1848 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1849 assert_eq!(info.last_exit, None);
1850 }
1851
1852 #[test]
1853 fn a_signal_request_and_its_reply_round_trip() {
1854 let request = Request::Signal {
1855 selector: SelectorSpec::Name("web".to_string()),
1856 signal: "SIGHUP".to_string(),
1857 };
1858 let json = serde_json::to_string(&request).unwrap();
1859 assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1860
1861 let reply = Response::Signalled(vec![
1862 SignalReply {
1863 id: 1,
1864 name: "web".to_string(),
1865 outcome: SignalOutcome::Delivered,
1866 },
1867 SignalReply {
1868 id: 2,
1869 name: "web".to_string(),
1870 outcome: SignalOutcome::NotRunning,
1871 },
1872 SignalReply {
1873 id: 3,
1874 name: "api".to_string(),
1875 outcome: SignalOutcome::Failed {
1876 reason: "no such process".to_string(),
1877 },
1878 },
1879 ]);
1880 let json = serde_json::to_string(&reply).unwrap();
1881 assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), reply);
1882 // The three tags, spelled out: a variant renamed in Rust changes
1883 // these strings, compiles clean, and breaks a client matching on them.
1884 assert!(json.contains(r#""kind":"delivered""#), "{json}");
1885 assert!(json.contains(r#""kind":"not_running""#), "{json}");
1886 assert!(json.contains(r#""kind":"failed""#), "{json}");
1887 }
1888
1889 /// `instances` is a per-app number, so `shep stock /web.*/ 4` could mean
1890 /// four each or four total.
1891 #[test]
1892 fn a_scale_request_names_one_app_and_a_count() {
1893 let request = Request::Scale {
1894 name: "web".to_string(),
1895 count: 4,
1896 };
1897 let json = serde_json::to_string(&request).unwrap();
1898 assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1899 assert!(json.contains(r#""kind":"scale""#), "{json}");
1900 assert!(json.contains(r#""name":"web""#), "{json}");
1901 // No `selector` key at all: this verb is not one of the
1902 // selector-taking family.
1903 assert!(!json.contains("selector"), "{json}");
1904 }
1905
1906 #[test]
1907 fn a_scaled_reply_carries_its_own_tag() {
1908 let json = serde_json::to_string(&Response::Scaled(vec![])).unwrap();
1909 assert_eq!(json, r#"{"kind":"scaled","data":[]}"#);
1910 }
1911
1912 /// `Add` and `Start` carry byte-identical payloads and differ by their
1913 /// `kind` alone.
1914 #[test]
1915 fn an_add_request_and_its_reply_round_trip() {
1916 let request = Request::Add {
1917 apps: vec![AppConfig::minimal("web", "./srv")],
1918 };
1919 let json = serde_json::to_string(&request).unwrap();
1920 assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1921 assert!(json.contains(r#""kind":"add""#), "{json}");
1922
1923 let reply = Response::Added(vec![]);
1924 let json = serde_json::to_string(&reply).unwrap();
1925 assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), reply);
1926 assert!(json.contains(r#""kind":"added""#), "{json}");
1927 }
1928
1929 /// `NotWritten`'s reason is the only thing separating "the app is not
1930 /// reading its stdin" from "the pipe broke".
1931 #[test]
1932 fn a_send_line_request_and_its_reply_round_trip() {
1933 let request = Request::SendLine {
1934 selector: SelectorSpec::Name("repl".to_string()),
1935 line: "reload-config".to_string(),
1936 };
1937 let json = serde_json::to_string(&request).unwrap();
1938 assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1939
1940 let reply = Response::SentLine(vec![
1941 LineReply {
1942 id: 1,
1943 name: "repl".to_string(),
1944 outcome: LineOutcome::Sent,
1945 },
1946 LineReply {
1947 id: 2,
1948 name: "web".to_string(),
1949 outcome: LineOutcome::NoStdin,
1950 },
1951 LineReply {
1952 id: 3,
1953 name: "stuck".to_string(),
1954 outcome: LineOutcome::NotWritten {
1955 reason: "the app did not read its stdin within 2s".to_string(),
1956 },
1957 },
1958 ]);
1959 let json = serde_json::to_string(&reply).unwrap();
1960 assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), reply);
1961 assert!(json.contains(r#""kind":"sent""#), "{json}");
1962 assert!(json.contains(r#""kind":"no_stdin""#), "{json}");
1963 assert!(json.contains("did not read its stdin"), "{json}");
1964 }
1965
1966 #[test]
1967 fn a_line_carrying_a_newline_is_still_one_field_on_the_wire() {
1968 let request = Request::SendLine {
1969 selector: SelectorSpec::All,
1970 line: "a\nb".to_string(),
1971 };
1972 let json = serde_json::to_string(&request).unwrap();
1973 // Escaped, not literal: the frame stays one JSON object. Refusing
1974 // it is the daemon's job, not serde's.
1975 assert!(json.contains(r#""line":"a\nb""#), "{json}");
1976 assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1977 }
1978
1979 /// Also pins that the newtype protecting this field costs the wire
1980 /// nothing: a bare string either way, so no fixture and no protocol
1981 /// version moves for it.
1982 #[test]
1983 fn env_value_debug_does_not_leak() {
1984 let request = Request::SetSheepEnv {
1985 name: "web".to_string(),
1986 key: "DATABASE_URL".to_string(),
1987 value: Some("postgres://user:hunter2@localhost/app".to_string().into()),
1988 };
1989 let debug = format!("{request:?}");
1990 assert!(!debug.contains("hunter2"), "{debug}");
1991 assert!(debug.contains("EnvValue(<37 bytes>)"), "{debug}");
1992
1993 let json = serde_json::to_string(&request).unwrap();
1994 assert!(
1995 json.contains(r#""value":"postgres://user:hunter2@localhost/app""#),
1996 "{json}"
1997 );
1998 assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1999 }
2000
2001 /// The pane edits everything else about a sheep, so the config itself
2002 /// has to travel; `env` is the one map in it that holds secrets, and
2003 /// the keys travel while the values never do (IR-41).
2004 #[test]
2005 fn a_sheep_config_view_never_carries_an_env_value() {
2006 let mut config = AppConfig::minimal("web", "./srv");
2007 config
2008 .env
2009 .insert("DB_PASS".to_string(), "hunter2".to_string());
2010 let view = SheepConfigView::new(config, Vec::new(), Vec::new());
2011 assert!(view.config.env.is_empty());
2012 assert_eq!(view.env_keys, ["DB_PASS"]);
2013 let json = serde_json::to_string(&view).unwrap();
2014 assert!(!json.contains("hunter2"), "{json}");
2015 }
2016
2017 /// A `{:?}` on a `Response` reaches it, and `config` holds `args` and
2018 /// `cwd` as well as the env keys (IR-41).
2019 #[test]
2020 fn a_sheep_config_views_debug_is_the_exact_redacted_string() {
2021 let mut config = AppConfig::minimal("web", "./srv");
2022 config.env.insert("A".to_string(), "1".to_string());
2023 let view = SheepConfigView::new(config, vec!["max_restarts".to_string()], Vec::new());
2024 assert_eq!(
2025 format!("{view:?}"),
2026 r#"SheepConfigView { name: "web", env_keys: 1, overridden: 1, pending: 0 }"#
2027 );
2028 }
2029
2030 #[test]
2031 fn request_wire_snapshots() {
2032 let requests = vec![
2033 Envelope {
2034 id: 1,
2035 deadline_ms: Some(5000),
2036 body: Request::Ping,
2037 },
2038 Envelope {
2039 id: 2,
2040 deadline_ms: None,
2041 body: Request::ListFlock,
2042 },
2043 Envelope {
2044 id: 3,
2045 deadline_ms: None,
2046 body: Request::Stop {
2047 selector: SelectorSpec::Name("web".to_string()),
2048 },
2049 },
2050 Envelope {
2051 id: 4,
2052 deadline_ms: None,
2053 body: Request::Start {
2054 apps: vec![AppConfig::minimal("web", "./srv")],
2055 },
2056 },
2057 // `All` rather than a named sheep: the selector `shep reopen`
2058 // sends when given no argument.
2059 Envelope {
2060 id: 5,
2061 deadline_ms: None,
2062 body: Request::Reopen {
2063 selector: SelectorSpec::All,
2064 },
2065 },
2066 // The same selector as the row above, so the two log-plane rows
2067 // differ by their `kind` and by nothing else.
2068 Envelope {
2069 id: 6,
2070 deadline_ms: None,
2071 body: Request::Flush {
2072 selector: SelectorSpec::All,
2073 },
2074 },
2075 // The same selector as the `stop` row: `reload` under `stop`'s tag
2076 // shows up here as two identical objects.
2077 Envelope {
2078 id: 7,
2079 deadline_ms: None,
2080 body: Request::Reload {
2081 selector: SelectorSpec::Name("web".to_string()),
2082 },
2083 },
2084 // `action`/`params` match channel.rs's with-params fixture
2085 // verbatim, so a trigger reads the same at every hop.
2086 Envelope {
2087 id: 8,
2088 deadline_ms: None,
2089 body: Request::Trigger {
2090 selector: SelectorSpec::Name("web".to_string()),
2091 action: "set-log-level".to_string(),
2092 params: Some("debug".to_string()),
2093 },
2094 },
2095 // A fieldless verb: a bare `{"kind":"..."}` with no `selector` key.
2096 Envelope {
2097 id: 9,
2098 deadline_ms: None,
2099 body: Request::SaveRoll,
2100 },
2101 // Paired with the `save_roll` row: they differ by their `kind` alone.
2102 Envelope {
2103 id: 10,
2104 deadline_ms: None,
2105 body: Request::Muster,
2106 },
2107 // The three dog verbs. `enable_dog` and `disable_dog` differ by
2108 // their `kind` and by `source` alone.
2109 Envelope {
2110 id: 11,
2111 deadline_ms: None,
2112 body: Request::DogConfig {
2113 name: "bark".to_string(),
2114 },
2115 },
2116 Envelope {
2117 id: 12,
2118 deadline_ms: None,
2119 body: Request::EnableDog {
2120 name: "metrics".to_string(),
2121 source: DogSource::BuiltIn,
2122 },
2123 },
2124 Envelope {
2125 id: 13,
2126 deadline_ms: None,
2127 body: Request::DisableDog {
2128 name: "metrics".to_string(),
2129 },
2130 },
2131 // `Id`, `Regex` and `Fold` are three newtypes the wire tells apart
2132 // only by their `kind` tag: a `Fold` under `regex`'s tag turns
2133 // `shep restart fold:api` into a regex match.
2134 Envelope {
2135 id: 14,
2136 deadline_ms: None,
2137 body: Request::Describe {
2138 selector: SelectorSpec::Id(7),
2139 },
2140 },
2141 Envelope {
2142 id: 15,
2143 deadline_ms: None,
2144 body: Request::Describe {
2145 selector: SelectorSpec::Regex("^web-".to_string()),
2146 },
2147 },
2148 Envelope {
2149 id: 16,
2150 deadline_ms: None,
2151 body: Request::Describe {
2152 selector: SelectorSpec::Fold("api".to_string()),
2153 },
2154 },
2155 // `SIGHUP` rather than `SIGTERM`: the stop ladder already sends
2156 // TERM, so a TERM fixture could not tell the two frames apart.
2157 Envelope {
2158 id: 17,
2159 deadline_ms: None,
2160 body: Request::Signal {
2161 selector: SelectorSpec::Name("web".to_string()),
2162 signal: "SIGHUP".to_string(),
2163 },
2164 },
2165 // The one verb here whose body has no `selector` key.
2166 Envelope {
2167 id: 18,
2168 deadline_ms: None,
2169 body: Request::Scale {
2170 name: "web".to_string(),
2171 count: 4,
2172 },
2173 },
2174 // The line carries no terminator on the wire, since the shepherd
2175 // appends it.
2176 Envelope {
2177 id: 19,
2178 deadline_ms: None,
2179 body: Request::SendLine {
2180 selector: SelectorSpec::All,
2181 line: "reload-config".to_string(),
2182 },
2183 },
2184 // Both halves of the `Option` are pinned, a paint and a clear, so a
2185 // dog author does not have to guess the clear frame's shape.
2186 Envelope {
2187 id: 20,
2188 deadline_ms: None,
2189 body: Request::SetSmit {
2190 sheep: "web".to_string(),
2191 smit: Some(
2192 "\u{25b2} main@a1b2c3"
2193 .parse()
2194 .expect("the reference smit is valid"),
2195 ),
2196 },
2197 },
2198 Envelope {
2199 id: 21,
2200 deadline_ms: None,
2201 body: Request::SetSmit {
2202 sheep: "web".to_string(),
2203 smit: None,
2204 },
2205 },
2206 // An empty `apps`: `start`'s row already pins the payload type, so
2207 // this row's own are the tag and the key the list travels under.
2208 Envelope {
2209 id: 22,
2210 deadline_ms: None,
2211 body: Request::ConfigDrift { apps: Vec::new() },
2212 },
2213 // The only struct-shaped `SelectorSpec` variant, so the only place
2214 // `"kind":"instance"` and the `slot` key are pinned.
2215 Envelope {
2216 id: 23,
2217 deadline_ms: None,
2218 body: Request::Restart {
2219 selector: SelectorSpec::Instance {
2220 name: "web".to_string(),
2221 slot: 2,
2222 },
2223 },
2224 },
2225 // The one request an older daemon must never be sent: shep-cli
2226 // gates it on the daemon's crate version.
2227 Envelope {
2228 id: 24,
2229 deadline_ms: None,
2230 body: Request::HandoverFitness,
2231 },
2232 // The second request gated on the daemon's crate version.
2233 Envelope {
2234 id: 25,
2235 deadline_ms: None,
2236 body: Request::DogStaleness,
2237 },
2238 // The only request carrying a `DeclaredApp`: a merge keys on what a
2239 // document claimed. `declared_env` is non-empty to show it holds
2240 // env key names and no env value, and `reset` is pinned at a
2241 // non-default depth.
2242 Envelope {
2243 id: 26,
2244 deadline_ms: None,
2245 body: Request::ApplyConfig {
2246 apps: vec![DeclaredApp {
2247 config: AppConfig::minimal("web", "./srv"),
2248 declared: ["name", "script"]
2249 .iter()
2250 .map(|k| (*k).to_string())
2251 .collect(),
2252 declared_env: ["DATABASE_URL"].iter().map(|k| (*k).to_string()).collect(),
2253 }],
2254 reset: ResetDepth::Policy,
2255 },
2256 },
2257 // The same app as the `start` row above: the two differ by their
2258 // `kind` alone, so a mis-tagged `add` shows up as two identical
2259 // objects.
2260 Envelope {
2261 id: 27,
2262 deadline_ms: None,
2263 body: Request::Add {
2264 apps: vec![AppConfig::minimal("web", "./srv")],
2265 },
2266 },
2267 // The four config-pane requests. `SheepConfig` takes a name
2268 // rather than a selector, like `Scale` and `SetSmit` above and
2269 // for their reason: a pane edits one sheep.
2270 Envelope {
2271 id: 28,
2272 deadline_ms: None,
2273 body: Request::SheepConfig {
2274 name: "web".to_string(),
2275 },
2276 },
2277 // `value` is pinned as `Some`, because the `None` spelling is
2278 // what removes the key, and a reader that guessed the two apart
2279 // wrongly would delete an operator's env instead of setting it.
2280 // The value is a placeholder, not a secret: this is the one
2281 // request in the enum that carries an env value at all, and it
2282 // travels in one direction only, nothing ever reads it back.
2283 Envelope {
2284 id: 29,
2285 deadline_ms: None,
2286 body: Request::SetSheepEnv {
2287 name: "web".to_string(),
2288 key: "DATABASE_URL".to_string(),
2289 value: Some("postgres://localhost/app".to_string().into()),
2290 },
2291 },
2292 // `SetSheepEnv`'s twin for everything that is not `env`, and
2293 // pinned beside it: the two are one letter apart in the tag and
2294 // a reader that crossed them would write a config field into an
2295 // env map. `value` is a bare JSON value rather than a string,
2296 // which is the half a hand-written reader gets wrong: an
2297 // integer field is an integer here, not `"32"`.
2298 Envelope {
2299 id: 30,
2300 deadline_ms: None,
2301 body: Request::SetSheepField {
2302 name: "web".to_string(),
2303 key: "max_restarts".to_string(),
2304 value: serde_json::json!(32),
2305 },
2306 },
2307 // The second request carrying a `DogSectionToml`, and pinned
2308 // beside its reader: `DogConfig` asks for a section and this
2309 // writes one back, so the two have to agree about the shape a
2310 // section takes on the wire.
2311 Envelope {
2312 id: 31,
2313 deadline_ms: None,
2314 body: Request::SetDogConfig {
2315 name: "bark".to_string(),
2316 toml: "debounce = \"30s\"\n".to_string().into(),
2317 },
2318 },
2319 ];
2320 insta::assert_json_snapshot!("request_wire_v4", requests);
2321 }
2322
2323 #[test]
2324 fn reply_wire_snapshots() {
2325 let replies = vec![
2326 Reply {
2327 id: 1,
2328 result: Ok(Response::Pong),
2329 },
2330 Reply {
2331 id: 2,
2332 result: Ok(Response::Flock(vec![sample_info()])),
2333 },
2334 Reply {
2335 id: 3,
2336 result: Err(RpcError {
2337 code: RpcErrorCode::NotFound,
2338 message: "no sheep matches `web`".to_string(),
2339 daemon_version: None,
2340 }),
2341 },
2342 // `ActionReply` is not a `ProcessInfo`. `Replied` is the
2343 // struct-shaped `ActionOutcome` variant and so the one worth
2344 // pinning.
2345 Reply {
2346 id: 4,
2347 result: Ok(Response::Triggered(vec![ActionReply {
2348 id: 3,
2349 name: "web".to_string(),
2350 outcome: ActionOutcome::Replied {
2351 body: "ok".to_string(),
2352 },
2353 }])),
2354 },
2355 // The only struct-shaped `Response` variant; every other one is
2356 // a newtype over a `Vec` or a unit, both proven above.
2357 Reply {
2358 id: 5,
2359 result: Ok(Response::RollSaved {
2360 path: "/home/ada/.shep/flock.json".to_string(),
2361 apps: 2,
2362 }),
2363 },
2364 // The present `dog` marker; `sample_info()` pins the absent one.
2365 // `Adopted` because it is the variant carrying a payload.
2366 Reply {
2367 id: 6,
2368 result: Ok(Response::Flock(vec![ProcessInfo {
2369 id: 7,
2370 name: "otel".to_string(),
2371 dog: Some(DogSource::Adopted {
2372 path: "/usr/local/bin/shep-otel".to_string(),
2373 }),
2374 ..sample_info()
2375 }])),
2376 },
2377 // The section crosses the wire as text, never a typed structure.
2378 Reply {
2379 id: 7,
2380 result: Ok(Response::DogSection {
2381 toml: "port = 9615\n".to_string().into(),
2382 }),
2383 },
2384 // The only `Response` variant carrying a bare `ProcessInfo`
2385 // rather than a `Vec`: `enable` starts exactly one dog.
2386 Reply {
2387 id: 8,
2388 result: Ok(Response::DogStarted(ProcessInfo {
2389 id: 4,
2390 name: "metrics".to_string(),
2391 dog: Some(DogSource::BuiltIn),
2392 ..sample_info()
2393 })),
2394 },
2395 // Each row below carries the smallest body that shows its wire
2396 // shape: the tag is what is being pinned. `Deleted` is a
2397 // `Vec<u32>`; `Subscribed` and `ShuttingDown` carry nothing.
2398 Reply {
2399 id: 9,
2400 result: Ok(Response::Described(vec![])),
2401 },
2402 Reply {
2403 id: 10,
2404 result: Ok(Response::Started(vec![])),
2405 },
2406 Reply {
2407 id: 11,
2408 result: Ok(Response::Stopped(vec![])),
2409 },
2410 Reply {
2411 id: 12,
2412 result: Ok(Response::Restarted(vec![])),
2413 },
2414 Reply {
2415 id: 13,
2416 result: Ok(Response::Reloading(vec![])),
2417 },
2418 Reply {
2419 id: 14,
2420 result: Ok(Response::Deleted(vec![7, 8])),
2421 },
2422 Reply {
2423 id: 15,
2424 result: Ok(Response::Reopened(vec![])),
2425 },
2426 Reply {
2427 id: 16,
2428 result: Ok(Response::Flushed(vec![])),
2429 },
2430 Reply {
2431 id: 17,
2432 result: Ok(Response::Mustered(vec![])),
2433 },
2434 Reply {
2435 id: 18,
2436 result: Ok(Response::Subscribed),
2437 },
2438 Reply {
2439 id: 19,
2440 result: Ok(Response::ShuttingDown),
2441 },
2442 // `Signalled`, mirroring the `Triggered` row: one row per
2443 // `SignalOutcome` variant, so no tag is left unproven.
2444 Reply {
2445 id: 20,
2446 result: Ok(Response::Signalled(vec![
2447 SignalReply {
2448 id: 1,
2449 name: "web".to_string(),
2450 outcome: SignalOutcome::Delivered,
2451 },
2452 SignalReply {
2453 id: 2,
2454 name: "web".to_string(),
2455 outcome: SignalOutcome::NotRunning,
2456 },
2457 SignalReply {
2458 id: 3,
2459 name: "api".to_string(),
2460 outcome: SignalOutcome::Failed {
2461 reason: "no such process".to_string(),
2462 },
2463 },
2464 ])),
2465 },
2466 Reply {
2467 id: 21,
2468 result: Ok(Response::Scaled(vec![sample_info()])),
2469 },
2470 // `SentLine`, mirroring the `Signalled` row: one row per
2471 // `LineOutcome` variant.
2472 Reply {
2473 id: 22,
2474 result: Ok(Response::SentLine(vec![
2475 LineReply {
2476 id: 1,
2477 name: "repl".to_string(),
2478 outcome: LineOutcome::Sent,
2479 },
2480 LineReply {
2481 id: 2,
2482 name: "web".to_string(),
2483 outcome: LineOutcome::NoStdin,
2484 },
2485 LineReply {
2486 id: 3,
2487 name: "stuck".to_string(),
2488 outcome: LineOutcome::NotWritten {
2489 reason: "the app did not read its stdin within 2s".to_string(),
2490 },
2491 },
2492 ])),
2493 },
2494 // A walked lamb tree; every other row pins the `null` shape.
2495 Reply {
2496 id: 23,
2497 result: Ok(Response::Described(vec![
2498 ProcessInfo::builder(3, "web", ProcStatus::Online)
2499 .pid(Some(4242))
2500 .lambs(Some(vec![Lamb::new(4243, "node"), Lamb::new(4244, "sh")]))
2501 .build(),
2502 ])),
2503 },
2504 // The killed-by-signal shape of `last_exit`; every row above pins
2505 // the exited-normally one. `SIGTERM`'s raw number, since this
2506 // crate carries no name for it.
2507 Reply {
2508 id: 24,
2509 result: Ok(Response::Flock(vec![
2510 ProcessInfo::builder(5, "worker", ProcStatus::Stopped)
2511 .restarts(1)
2512 .last_exit(Some(ExitInfo {
2513 code: None,
2514 signal: Some(15),
2515 }))
2516 .build(),
2517 ])),
2518 },
2519 // The one row that pins a smit on the wire; `sample_info()` carries
2520 // none.
2521 Reply {
2522 id: 25,
2523 result: Ok(Response::SmitPainted(vec![
2524 ProcessInfo::builder(3, "web", ProcStatus::Online)
2525 .pid(Some(4242))
2526 .smit(Some("\u{25b2} main@a1b2c3".to_string()))
2527 .build(),
2528 ])),
2529 },
2530 // A sheep drifting in one field and a sheep drifting in several.
2531 // `env` is one of them: the name travels and the value never does.
2532 Reply {
2533 id: 26,
2534 result: Ok(Response::Drifted(vec![
2535 SheepDrift::new("web", vec!["cwd".to_string()]),
2536 SheepDrift::new(
2537 "api",
2538 vec!["args".to_string(), "env".to_string(), "script".to_string()],
2539 ),
2540 ])),
2541 },
2542 // The present shape of `instance`; every row above pins its absence.
2543 Reply {
2544 id: 27,
2545 result: Ok(Response::Flock(vec![
2546 ProcessInfo::builder(9, "web", ProcStatus::Online)
2547 .pid(Some(5150))
2548 .instance(Some(2))
2549 .build(),
2550 ])),
2551 },
2552 // Both shapes of the handover answer; the difference between them
2553 // is a `null`.
2554 Reply {
2555 id: 28,
2556 result: Ok(Response::HandoverFitness { refusal: None }),
2557 },
2558 Reply {
2559 id: 29,
2560 result: Ok(Response::HandoverFitness {
2561 refusal: Some("sheep 'web' has a shepherd channel".to_string()),
2562 }),
2563 },
2564 // Both lists non-empty and different: the two carry the same wire
2565 // shape.
2566 Reply {
2567 id: 30,
2568 result: Ok(Response::DogStaleness {
2569 stale: vec!["metrics".to_string()],
2570 pending: vec!["bark".to_string()],
2571 }),
2572 },
2573 // A dog whose process is up and which has never answered this
2574 // shepherd. `dog_stale: false` is the silence still being waited
2575 // out; the row below is the one it has given up on.
2576 Reply {
2577 id: 31,
2578 result: Ok(Response::Flock(vec![
2579 ProcessInfo::builder(10, "log-rotate", ProcStatus::Online)
2580 .pid(Some(208_341))
2581 .dog(Some(DogSource::Adopted {
2582 path: "/usr/local/bin/shep-log-rotate".to_string(),
2583 }))
2584 .handshook(Some(false))
2585 .dog_stale(Some(false))
2586 .build(),
2587 ])),
2588 },
2589 Reply {
2590 id: 32,
2591 result: Ok(Response::Flock(vec![
2592 ProcessInfo::builder(10, "log-rotate", ProcStatus::Online)
2593 .pid(Some(208_341))
2594 .dog(Some(DogSource::Adopted {
2595 path: "/usr/local/bin/shep-log-rotate".to_string(),
2596 }))
2597 .handshook(Some(false))
2598 .dog_stale(Some(true))
2599 .build(),
2600 ])),
2601 },
2602 // Three entries, one per shape a load produces: applied, pending,
2603 // refused. `env` is a pending name on purpose: the name travels
2604 // and the value never does.
2605 Reply {
2606 id: 32,
2607 result: Ok(Response::Applied(vec![
2608 SheepApplied::new("web", vec!["max_memory".to_string()], Vec::new(), None),
2609 SheepApplied::new(
2610 "api",
2611 Vec::new(),
2612 vec!["args".to_string(), "env".to_string()],
2613 None,
2614 ),
2615 SheepApplied::new(
2616 "worker",
2617 Vec::new(),
2618 Vec::new(),
2619 Some("worker is not registered".to_string()),
2620 ),
2621 ])),
2622 },
2623 // `Added`'s tag, all a fixture can prove for a `Vec<ProcessInfo>`
2624 // variant. Down here because every id in this vector is
2625 // hand-written.
2626 Reply {
2627 id: 33,
2628 result: Ok(Response::Added(vec![])),
2629 },
2630 // The config pane's answer, and the row that proves its whole
2631 // security property: `env` serializes as an empty object while
2632 // `env_keys` names the key beside it, so an out-of-tree reader
2633 // learns here that a value never travels (IR-41).
2634 Reply {
2635 id: 34,
2636 result: Ok(Response::SheepConfig(Box::new(SheepConfigView::new(
2637 {
2638 let mut config = AppConfig::minimal("web", "./srv");
2639 config
2640 .env
2641 .insert("DATABASE_URL".to_string(), "postgres://x".to_string());
2642 config
2643 },
2644 vec!["max_restarts".to_string()],
2645 vec!["env".to_string()],
2646 )))),
2647 },
2648 // The three acknowledgements. None echoes what was written:
2649 // `SheepEnvSet` names the key and not its value, for the reason
2650 // the row above pins, `SheepFieldSet` does the same and adds
2651 // the one bit the caller cannot derive, and `DogConfigSet`
2652 // names the dog and not the section.
2653 Reply {
2654 id: 35,
2655 result: Ok(Response::SheepEnvSet {
2656 name: "web".to_string(),
2657 key: "DATABASE_URL".to_string(),
2658 }),
2659 },
2660 // `pending` pinned `true`, because `false` is the value a reader
2661 // that dropped the field entirely would decode by accident, and
2662 // the two answers send an operator to different places: one
2663 // says the change is in force, the other says to reload.
2664 Reply {
2665 id: 36,
2666 result: Ok(Response::SheepFieldSet {
2667 name: "web".to_string(),
2668 key: "script".to_string(),
2669 pending: true,
2670 }),
2671 },
2672 Reply {
2673 id: 37,
2674 result: Ok(Response::DogConfigSet {
2675 name: "bark".to_string(),
2676 }),
2677 },
2678 ];
2679 insta::assert_json_snapshot!("reply_wire_v4", replies);
2680 }
2681
2682 /// Asserts on the JSON, not the struct: a `Vec<String>` cannot say which
2683 /// of the two a string is, so a build carrying a value would typecheck.
2684 #[test]
2685 fn a_sheep_applied_carries_names_and_never_values() {
2686 let applied = SheepApplied::new(
2687 "web",
2688 vec!["cwd".to_string()],
2689 vec!["env".to_string()],
2690 None,
2691 );
2692 let json = serde_json::to_string(&applied).unwrap();
2693 assert!(json.contains("\"env\""), "the NAME travels: {json}");
2694 assert!(
2695 !json.contains("DATABASE_URL"),
2696 "and no value ever does: {json}"
2697 );
2698 }
2699
2700 #[test]
2701 fn a_sheep_applied_debug_prints_the_names_it_was_given() {
2702 let applied = SheepApplied::new("web", vec!["cwd".to_string()], Vec::new(), None);
2703 assert_eq!(
2704 format!("{applied:?}"),
2705 "SheepApplied { name: \"web\", applied: [\"cwd\"], pending: [], refused: None }"
2706 );
2707 }
2708
2709 #[test]
2710 fn a_process_info_without_a_smit_key_still_deserializes() {
2711 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}"#;
2712 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2713 assert_eq!(info.smit, None);
2714 }
2715
2716 /// The fixture is a dog's row, where `None` means "render this as it
2717 /// rendered before the field existed", never "never handshaken".
2718 #[test]
2719 fn a_process_info_without_a_handshook_key_still_deserializes() {
2720 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}"#;
2721 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2722 assert_eq!(info.handshook, None);
2723 assert_eq!(info.dog, Some(DogSource::BuiltIn));
2724 }
2725
2726 /// The fixture carries `handshook: false`, the case that matters: `None`
2727 /// is "no verdict to report", never "it has not given up".
2728 #[test]
2729 fn a_process_info_without_a_dog_stale_key_still_deserializes() {
2730 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}"#;
2731 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2732 assert_eq!(info.dog_stale, None);
2733 assert_eq!(info.handshook, Some(false));
2734 }
2735
2736 #[test]
2737 fn a_process_info_carries_its_memory_ceiling_and_defaults_to_none() {
2738 let plain = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
2739 assert_eq!(
2740 plain.max_memory, None,
2741 "a sheep with no ceiling reports none"
2742 );
2743
2744 let capped = ProcessInfo::builder(2, "hungry", ProcStatus::Online)
2745 .max_memory(Some(52 * 1024 * 1024))
2746 .build();
2747 assert_eq!(capped.max_memory, Some(54_525_952));
2748 }
2749
2750 #[test]
2751 fn an_older_daemons_process_info_still_decodes() {
2752 // The field is additive, so a payload written before it existed has to
2753 // decode with the ceiling absent rather than fail the whole envelope.
2754 let older = r#"{"id":1,"name":"web","status":"online","restarts":0,"uptime_ms":0}"#;
2755 let info: ProcessInfo = serde_json::from_str(older).expect("an older payload decodes");
2756 assert_eq!(info.max_memory, None);
2757 }
2758
2759 /// A dog written in another language speaks this wire directly and never
2760 /// runs `FromStr`.
2761 #[test]
2762 fn a_smit_is_validated_when_it_is_deserialized_not_only_when_parsed() {
2763 for bad in [
2764 r#""\u001b[2Jgone""#.to_string(), // an escape
2765 r#""a\nb""#.to_string(), // a newline
2766 r#""""#.to_string(), // empty
2767 r#"" ""#.to_string(), // whitespace
2768 format!(r#""{}""#, "x".repeat(Smit::MAX_CHARS + 1)), // too long
2769 ] {
2770 assert!(
2771 serde_json::from_str::<Smit>(&bad).is_err(),
2772 "a daemon must refuse this on the wire: {bad}"
2773 );
2774 }
2775 assert!(serde_json::from_str::<Smit>(r#""\u25b2 main@a1b2c3""#).is_ok());
2776 }
2777
2778 /// The hand-written `Deserialize` agrees with the derived `Serialize`
2779 /// only while the serialize side stays transparent.
2780 #[test]
2781 fn a_smit_travels_as_a_bare_string() {
2782 let smit: Smit = "\u{25b2} main@a1b2c3".parse().expect("valid");
2783 let json = serde_json::to_string(&smit).unwrap();
2784 assert_eq!(json, "\"\u{25b2} main@a1b2c3\"");
2785 assert_eq!(serde_json::from_str::<Smit>(&json).unwrap(), smit);
2786 }
2787
2788 #[test]
2789 fn a_smit_is_capped_in_characters_not_bytes() {
2790 let cjk = "\u{7f8a}".repeat(Smit::MAX_CHARS);
2791 assert_eq!(cjk.len(), Smit::MAX_CHARS * 3);
2792 assert!(cjk.parse::<Smit>().is_ok(), "{cjk}");
2793 assert_eq!(
2794 "x".repeat(Smit::MAX_CHARS + 1).parse::<Smit>(),
2795 Err(SmitError::TooLong {
2796 chars: Smit::MAX_CHARS + 1
2797 })
2798 );
2799 }
2800
2801 #[test]
2802 fn a_smit_is_stored_exactly_as_it_arrived() {
2803 let padded: Smit = " main@a1b2c3 ".parse().expect("valid");
2804 assert_eq!(padded.as_str(), " main@a1b2c3 ");
2805 assert_eq!(padded.to_string(), " main@a1b2c3 ");
2806 }
2807
2808 #[test]
2809 fn v1_fixture_still_deserializes() {
2810 // Committed byte fixture from protocol v1. If this breaks, bump
2811 // PROTOCOL_VERSION and record it in the CHANGELOG.
2812 let fixture = r#"{"id":7,"deadline_ms":null,"body":{"kind":"stop","selector":{"kind":"name","value":"web"}}}"#;
2813 let env: Envelope = serde_json::from_str(fixture).unwrap();
2814 assert_eq!(env.id, 7);
2815 assert!(matches!(
2816 env.body,
2817 Request::Stop { selector: SelectorSpec::Name(ref n) } if n == "web"
2818 ));
2819 }
2820
2821 #[test]
2822 fn hello_handshake_shape() {
2823 let hello = Hello {
2824 client_version: "0.1.0".to_string(),
2825 protocol: PROTOCOL_VERSION,
2826 dog_name: None,
2827 };
2828 let json = serde_json::to_string(&hello).unwrap();
2829 assert_eq!(json, r#"{"client_version":"0.1.0","protocol":4}"#);
2830 }
2831
2832 #[test]
2833 fn a_dogs_hello_names_the_dog_and_nothing_elses_does() {
2834 let dog = Hello {
2835 client_version: "0.1.0".to_string(),
2836 protocol: PROTOCOL_VERSION,
2837 dog_name: Some("metrics".to_string()),
2838 };
2839 let json = serde_json::to_string(&dog).unwrap();
2840 assert_eq!(
2841 json,
2842 r#"{"client_version":"0.1.0","protocol":4,"dog_name":"metrics"}"#
2843 );
2844 assert_eq!(serde_json::from_str::<Hello>(&json).unwrap(), dog);
2845 }
2846
2847 /// `Hello` is the version-negotiation frame, so `deny_unknown_fields`
2848 /// here would refuse a newer client before `protocol` is read, leaving
2849 /// neither peer able to report the skew.
2850 #[test]
2851 fn a_hello_without_a_dog_name_still_parses() {
2852 let fixture = r#"{"client_version":"0.1.14","protocol":2}"#;
2853 let hello: Hello = serde_json::from_str(fixture).unwrap();
2854 assert_eq!(hello.protocol, 2);
2855 assert_eq!(hello.dog_name, None);
2856
2857 // The other direction: an older daemon ignores a key it does not
2858 // know. `unknown_to_an_older_daemon` stands in for `dog_name`.
2859 let newer = r#"{"client_version":"9.9.9","protocol":2,"dog_name":"metrics","unknown_to_an_older_daemon":true}"#;
2860 let hello: Hello = serde_json::from_str(newer).unwrap();
2861 assert_eq!(hello.protocol, 2);
2862 assert_eq!(hello.dog_name.as_deref(), Some("metrics"));
2863 }
2864
2865 #[test]
2866 fn hello_reply_carries_typed_skew_error() {
2867 let refusal: HelloReply = Err(RpcError {
2868 code: RpcErrorCode::ProtocolMismatch,
2869 message: "daemon speaks protocol 1, client sent 2".to_string(),
2870 daemon_version: None,
2871 });
2872 let json = serde_json::to_string(&refusal).unwrap();
2873 assert_eq!(
2874 json,
2875 r#"{"Err":{"code":"protocol_mismatch","message":"daemon speaks protocol 1, client sent 2"}}"#
2876 );
2877 let back: HelloReply = serde_json::from_str(&json).unwrap();
2878 assert_eq!(back, refusal);
2879 }
2880
2881 #[test]
2882 fn v1_reply_fixture_still_deserializes() {
2883 // Committed byte fixture, protocol v1.
2884 let ok = r#"{"id":1,"result":{"Ok":{"kind":"pong"}}}"#;
2885 let reply: Reply = serde_json::from_str(ok).unwrap();
2886 assert!(matches!(reply.result, Ok(Response::Pong)));
2887 let err = r#"{"id":2,"result":{"Err":{"code":"not_found","message":"no sheep"}}}"#;
2888 let reply: Reply = serde_json::from_str(err).unwrap();
2889 assert_eq!(reply.result.unwrap_err().code, RpcErrorCode::NotFound);
2890 }
2891
2892 #[test]
2893 fn v1_hello_ack_fixture_still_deserializes() {
2894 let fixture = r#"{"Ok":{"daemon_version":"0.1.0","protocol":1,"pid":4242}}"#;
2895 let ack: HelloReply = serde_json::from_str(fixture).unwrap();
2896 assert_eq!(ack.unwrap().pid, 4242);
2897 }
2898
2899 #[test]
2900 fn v1_process_info_without_stats_still_deserializes() {
2901 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"}"#;
2902 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2903 assert_eq!(info.cpu_percent, None);
2904 assert_eq!(info.memory_bytes, None);
2905 }
2906
2907 #[test]
2908 fn v1_process_info_without_log_paths_still_deserializes() {
2909 // Committed byte fixture from before `out_file`/`err_file` existed.
2910 let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend"}"#;
2911 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2912 assert_eq!(info.id, 3);
2913 assert_eq!(info.out_file, None);
2914 assert_eq!(info.err_file, None);
2915 }
2916
2917 #[test]
2918 fn an_old_client_still_decodes_a_new_process_info() {
2919 // `ProcessInfo` carries no `deny_unknown_fields`, unlike the config
2920 // types in `crate::config`, so extra keys are ignored.
2921 #[derive(Deserialize)]
2922 struct V1ProcessInfo {
2923 id: u32,
2924 fold: Option<String>,
2925 }
2926
2927 let current = serde_json::to_string(&sample_info()).unwrap();
2928 let old: V1ProcessInfo = serde_json::from_str(¤t).unwrap();
2929 assert_eq!(old.id, 3);
2930 assert_eq!(old.fold.as_deref(), Some("backend"));
2931 }
2932
2933 #[test]
2934 fn an_rpc_error_without_a_daemon_version_serializes_exactly_as_before() {
2935 // `skip_serializing_if` is what makes the field free: no
2936 // `"daemon_version":null` key for an older client to ignore.
2937 let plain = RpcError {
2938 code: RpcErrorCode::NotFound,
2939 message: "no sheep".to_string(),
2940 daemon_version: None,
2941 };
2942 assert_eq!(
2943 serde_json::to_string(&plain).unwrap(),
2944 r#"{"code":"not_found","message":"no sheep"}"#
2945 );
2946 }
2947
2948 #[test]
2949 fn a_v1_rpc_error_fixture_deserializes_with_no_daemon_version() {
2950 let fixture =
2951 r#"{"code":"protocol_mismatch","message":"daemon speaks protocol 1, client sent 2"}"#;
2952 let err: RpcError = serde_json::from_str(fixture).unwrap();
2953 assert_eq!(err.code, RpcErrorCode::ProtocolMismatch);
2954 assert_eq!(err.daemon_version, None);
2955 }
2956
2957 #[test]
2958 fn an_old_client_ignores_an_rpc_error_field_it_has_never_seen() {
2959 // `RpcError` carries no `deny_unknown_fields`, so an optional field
2960 // may be added without moving `PROTOCOL_VERSION`.
2961 #[derive(Deserialize)]
2962 struct OldRpcError {
2963 code: RpcErrorCode,
2964 message: String,
2965 }
2966
2967 let current = serde_json::to_string(&RpcError {
2968 code: RpcErrorCode::ProtocolMismatch,
2969 message: "daemon speaks protocol 1, client sent 2".to_string(),
2970 daemon_version: Some("0.1.16".to_string()),
2971 })
2972 .unwrap();
2973 let old: OldRpcError = serde_json::from_str(¤t).expect("must tolerate");
2974 assert_eq!(old.code, RpcErrorCode::ProtocolMismatch);
2975 assert_eq!(old.message, "daemon speaks protocol 1, client sent 2");
2976 }
2977
2978 #[test]
2979 fn deadline_exceeded_code_serializes_snake_case() {
2980 assert_eq!(
2981 serde_json::to_string(&RpcErrorCode::DeadlineExceeded).unwrap(),
2982 "\"deadline_exceeded\""
2983 );
2984 assert_eq!(
2985 serde_json::from_str::<RpcErrorCode>("\"deadline_exceeded\"").unwrap(),
2986 RpcErrorCode::DeadlineExceeded
2987 );
2988 }
2989
2990 #[test]
2991 fn action_outcome_kinds_serialize_snake_case_and_round_trip() {
2992 // The shared snapshots exercise only `Replied`, the struct-shaped
2993 // variant.
2994 let cases = [
2995 (
2996 ActionOutcome::Replied {
2997 body: "pong".to_string(),
2998 },
2999 r#"{"kind":"replied","body":"pong"}"#,
3000 ),
3001 (ActionOutcome::NoChannel, r#"{"kind":"no_channel"}"#),
3002 (ActionOutcome::Skipped, r#"{"kind":"skipped"}"#),
3003 (ActionOutcome::TimedOut, r#"{"kind":"timed_out"}"#),
3004 ];
3005 for (outcome, wire) in cases {
3006 assert_eq!(
3007 serde_json::to_string(&outcome).unwrap(),
3008 wire,
3009 "{outcome:?}"
3010 );
3011 assert_eq!(
3012 serde_json::from_str::<ActionOutcome>(wire).unwrap(),
3013 outcome
3014 );
3015 }
3016 }
3017
3018 #[test]
3019 fn save_roll_serializes_snake_case_with_its_payload_under_data() {
3020 assert_eq!(
3021 serde_json::to_string(&Request::SaveRoll).unwrap(),
3022 r#"{"kind":"save_roll"}"#
3023 );
3024 let reply = Response::RollSaved {
3025 path: "/tmp/flock.json".to_string(),
3026 apps: 3,
3027 };
3028 let wire = r#"{"kind":"roll_saved","data":{"path":"/tmp/flock.json","apps":3}}"#;
3029 assert_eq!(serde_json::to_string(&reply).unwrap(), wire);
3030 assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), reply);
3031 }
3032
3033 /// The listing is empty on purpose: `reply_wire_snapshots` pins the row
3034 /// field by field.
3035 #[test]
3036 fn muster_serializes_snake_case_with_its_listing_under_data() {
3037 assert_eq!(
3038 serde_json::to_string(&Request::Muster).unwrap(),
3039 r#"{"kind":"muster"}"#
3040 );
3041 let reply = Response::Mustered(Vec::new());
3042 let wire = r#"{"kind":"mustered","data":[]}"#;
3043 assert_eq!(serde_json::to_string(&reply).unwrap(), wire);
3044 assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), reply);
3045 }
3046
3047 #[test]
3048 fn the_dog_verbs_serialize_snake_case_with_their_payloads_under_data() {
3049 assert_eq!(
3050 serde_json::to_string(&Request::DogConfig {
3051 name: "bark".to_string()
3052 })
3053 .unwrap(),
3054 r#"{"kind":"dog_config","name":"bark"}"#
3055 );
3056 assert_eq!(
3057 serde_json::to_string(&Request::DisableDog {
3058 name: "bark".to_string()
3059 })
3060 .unwrap(),
3061 r#"{"kind":"disable_dog","name":"bark"}"#
3062 );
3063 let section = Response::DogSection {
3064 toml: "port = 9615\n".to_string().into(),
3065 };
3066 let wire = r#"{"kind":"dog_section","data":{"toml":"port = 9615\n"}}"#;
3067 assert_eq!(serde_json::to_string(§ion).unwrap(), wire);
3068 assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), section);
3069 }
3070
3071 #[test]
3072 fn dog_section_toml_debug_does_not_leak() {
3073 // A dog's section routinely holds webhook credentials. Pinned as an
3074 // exact string, so a `#[derive(Debug)]` on `DogSectionToml` fails
3075 // here.
3076 let toml: DogSectionToml =
3077 "webhook_url = \"https://discord.com/api/webhooks/1/super-secret-token\"\n"
3078 .to_string()
3079 .into();
3080 assert_eq!(format!("{toml:?}"), "DogSectionToml(<70 bytes>)");
3081
3082 let response = Response::DogSection { toml };
3083 assert_eq!(
3084 format!("{response:?}"),
3085 "DogSection { toml: DogSectionToml(<70 bytes>) }"
3086 );
3087 }
3088
3089 /// The fixture cannot agree under either candidate order: by id it is
3090 /// `web/1, api/2, web/0`, by name `api, web, web`. The two `web` rows
3091 /// are the tiebreak half, seeded out of order.
3092 #[test]
3093 fn a_listing_sorts_by_name_then_by_id() {
3094 let mut listing = vec![
3095 ProcessInfo::builder(1, "web", ProcStatus::Online).build(),
3096 ProcessInfo::builder(2, "api", ProcStatus::Online).build(),
3097 ProcessInfo::builder(0, "web", ProcStatus::Online).build(),
3098 ];
3099 sort_flock(&mut listing);
3100
3101 let seen: Vec<(&str, u32)> = listing
3102 .iter()
3103 .map(|info| (info.name.as_str(), info.id))
3104 .collect();
3105 assert_eq!(
3106 seen,
3107 vec![("api", 2), ("web", 0), ("web", 1)],
3108 "name first, then id inside a name"
3109 );
3110 }
3111
3112 #[test]
3113 fn an_instance_slot_survives_a_round_trip_and_defaults_to_absent() {
3114 let with = ProcessInfo::builder(1, "web", ProcStatus::Online)
3115 .instance(Some(2))
3116 .build();
3117 assert_eq!(with.instance, Some(2));
3118
3119 let without = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
3120 assert_eq!(
3121 without.instance, None,
3122 "a row nobody set a slot on says so, rather than claiming slot 0"
3123 );
3124 }
3125
3126 #[test]
3127 fn a_reply_from_a_daemon_without_the_field_deserializes_as_absent() {
3128 let json = r#"{"id":1,"name":"web","status":"online","pid":null,
3129 "restarts":0,"uptime_ms":0,"fold":null,"out_file":null,
3130 "err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":null,
3131 "lambs":null,"last_exit":null,"smit":null}"#;
3132 let info: ProcessInfo = serde_json::from_str(json).expect("older reply still parses");
3133 assert_eq!(info.instance, None);
3134 }
3135
3136 #[test]
3137 fn sort_flock_orders_by_slot_before_id() {
3138 // A reload gave slot 0 a fresh, higher id. Slot order must still win.
3139 let mut listing = vec![
3140 ProcessInfo::builder(9, "web", ProcStatus::Online)
3141 .instance(Some(0))
3142 .build(),
3143 ProcessInfo::builder(2, "web", ProcStatus::Online)
3144 .instance(Some(1))
3145 .build(),
3146 ];
3147 sort_flock(&mut listing);
3148 assert_eq!(
3149 listing.iter().map(|i| i.id).collect::<Vec<_>>(),
3150 vec![9, 2],
3151 "slot 0 leads even though its id is higher"
3152 );
3153 }
3154
3155 #[test]
3156 fn sort_flock_falls_back_to_id_when_no_row_carries_a_slot() {
3157 let mut listing = vec![
3158 ProcessInfo::builder(5, "web", ProcStatus::Online).build(),
3159 ProcessInfo::builder(3, "web", ProcStatus::Online).build(),
3160 ];
3161 sort_flock(&mut listing);
3162 assert_eq!(
3163 listing.iter().map(|i| i.id).collect::<Vec<_>>(),
3164 vec![3, 5],
3165 "an older daemon's listing sorts exactly as it does today"
3166 );
3167 }
3168}