Skip to main content

shep_core/protocol/request/
response.rs

1//! [`Response`], one variant per request kind, and the host reading one of them carries.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::config::DogTable;
8
9use super::{
10    ActionReply, DogSectionToml, LineReply, ProcessInfo, SheepApplied, SheepConfigView, SheepDrift,
11    SheepRefusal, SignalReply,
12};
13
14// Named by intra-doc links and by nothing rustc compiles, so the
15// import is behind `cfg(doc)` rather than flagged unused.
16#[cfg(doc)]
17use super::{Request, RpcErrorCode, sort_flock};
18#[cfg(doc)]
19use crate::config::AppConfig;
20
21/// The machine the flock runs on, as the shepherd last read it.
22///
23/// Three of the four numbers are rates, so none of them exists in a single
24/// moment: CPU, disk traffic and network traffic are all differences between
25/// two readings of a counter the kernel only ever increments. The shepherd
26/// keeps the earlier reading between its own ticks and serves the
27/// difference, which is what lets a one-shot listing carry them at all.
28///
29/// Answered by [`Response::HostUsage`], and by nothing else: this is
30/// deliberately not a field on [`Response::Flock`], whose array shape
31/// predates it.
32#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
33pub struct HostUsage {
34    /// Every core's usage over the shepherd's last window, as one percentage
35    /// of one whole machine.
36    ///
37    /// `None` where no window long enough to divide by has passed yet, which
38    /// on a shepherd that has just booted it has not. Absent is not idle.
39    pub cpu_percent: Option<f32>,
40    /// Memory in use, as the platform reports it. Not a rate, so never
41    /// absent.
42    pub memory_used_bytes: u64,
43    /// Total physical memory.
44    pub memory_total_bytes: u64,
45    /// Bytes a second the block devices read and wrote over that window,
46    /// absent on [`Self::cpu_percent`]'s terms.
47    pub disk_bytes_per_second: Option<(u64, u64)>,
48    /// Bytes a second the non-loopback interfaces received and transmitted
49    /// over that window, absent on the same terms.
50    pub network_bytes_per_second: Option<(u64, u64)>,
51}
52
53/// One RPC response (pairs with [`Request`] variants)
54///
55/// Ten variants carry a bare `Vec<ProcessInfo>`. Do not collapse them into
56/// one: each names which request it answers, which is what lets a variant
57/// diverge without a protocol bump. `Reloading` already means an acceptance
58/// rather than a result, `Scaled` only the survivors of a scale-down, and
59/// `Mustered` every sheep of every restored app rather than what this call
60/// started.
61// wire format: changing existing variants is a breaking change.
62// `large_enum_variant` allowed, not fixed: clippy's remedy is to box
63// `DogStarted`'s payload, a source break for every
64// `Response::DogStarted(info)` in and out of this workspace.
65#[allow(clippy::large_enum_variant)]
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
68#[non_exhaustive]
69pub enum Response {
70    /// Answer to `Ping`
71    Pong,
72    /// Answer to `ListFlock`
73    Flock(Vec<ProcessInfo>),
74    /// Answer to `HostUsage`
75    ///
76    /// `None` on a platform `sysinfo` cannot read at all, which is a state a
77    /// caller renders rather than an error. A supported platform with no
78    /// window yet answers `Some`, carrying the memory it could read and
79    /// `None` for each rate it could not.
80    HostUsage(Option<HostUsage>),
81    /// Answer to `Describe`
82    Described(Vec<ProcessInfo>),
83    /// Answer to `Start`
84    Started(Vec<ProcessInfo>),
85    /// Answer to `Add`: one row per app the request named, registered and
86    /// spawning nothing.
87    ///
88    /// A row here can still be `Online`: `Add` is idempotent by name, so the
89    /// reply describes the membership the request leaves behind.
90    Added(Vec<ProcessInfo>),
91    /// Answer to `ConfigDrift`: one entry per app that is registered under a
92    /// config different from the one asked about, and no entry for anything
93    /// else. An empty vector means every app asked about either matches or
94    /// is not registered at all.
95    Drifted(Vec<SheepDrift>),
96    /// Answer to `ApplyConfig`: one entry per app the request named, in the
97    /// order it named them, the refused and the unchanged included.
98    ///
99    /// Complete where [`Self::Drifted`] is filtered: an app missing from
100    /// "what did you do to each of these" looks like one the daemon dropped.
101    Applied(Vec<SheepApplied>),
102    /// Answer to `SheepConfig`: one sheep's config with `env` emptied and
103    /// its keys listed beside it.
104    ///
105    /// Boxed, and the only variant here that is. This one carries a whole
106    /// [`AppConfig`], which is several times the size of anything else in
107    /// the enum, and a `Response` is inside a `Reply` which is inside a
108    /// [`ServerFrame`](crate::protocol::ServerFrame): without the box,
109    /// every frame the daemon sends costs the largest config's worth of
110    /// stack for a variant almost none of them use.
111    ///
112    /// The enum-level `#[allow(clippy::large_enum_variant)]` below does not
113    /// cover it, and the difference is the point of that allow's own
114    /// argument: boxing `DogStarted` would be a source break for every
115    /// `Response::DogStarted(info)` in and out of this workspace, where
116    /// this variant has never shipped and so breaks nobody.
117    ///
118    /// `Box<T>` serializes exactly as `T`, so the wire bytes and the pinned
119    /// fixtures are untouched.
120    SheepConfig(Box<SheepConfigView>),
121    /// Answer to `SetSheepEnv`: the key that was set or removed.
122    ///
123    /// Never the value, and never the resulting env map. This reply exists
124    /// to confirm which key moved, and echoing what was just written back
125    /// down a socket would undo the whole point of `SheepConfig` withholding
126    /// it (IR-41).
127    SheepEnvSet {
128        /// The sheep.
129        name: String,
130        /// The key.
131        key: String,
132    },
133    /// What a [`Request::SetSheepEnvBatch`] did, or would have done.
134    ///
135    /// Key names only. `set` is what was written, `unchanged` what already
136    /// held the same value, `collisions` what held a different one. A
137    /// forced request reports a collision in both `set` and `collisions`;
138    /// an unforced one that collides reports an empty `set` and wrote
139    /// nothing.
140    SheepEnvBatch {
141        /// The sheep's name.
142        name: String,
143        /// Keys written.
144        set: Vec<String>,
145        /// Keys that already held this value.
146        unchanged: Vec<String>,
147        /// Keys that held a different value.
148        collisions: Vec<String>,
149    },
150    /// Answer to `SetSheepField`: which field moved, and whether the
151    /// running child has it.
152    ///
153    /// Not [`Self::Applied`]'s three lists; the difference is the
154    /// request's own shape. `applied`, `pending` and `refused` exist
155    /// because `ApplyConfig` carries N apps of M fields, so a caller cannot
156    /// otherwise tell which field went where or that one app of eleven was
157    /// refused. This request carries one field of one sheep, so `refused`
158    /// would be a second way to say no beside the `Err` arm (a client
159    /// checking only the `Err` would silently swallow the other), and the
160    /// two lists collapse to the one bit that is left.
161    ///
162    /// That bit is not redundant with the field's own
163    /// [`ApplyGroup`](crate::config::ApplyGroup), which the caller already
164    /// knows. It is the daemon's answer about state a caller cannot see:
165    /// `autostart` is `NextSpawn` and yet reports as in force, because it
166    /// is read at muster rather than at a spawn, and a `Live` field whose
167    /// config subset will not normalize on its own parks instead of
168    /// applying.
169    SheepFieldSet {
170        /// The sheep.
171        name: String,
172        /// The field that moved.
173        key: String,
174        /// `true` when the running child does not have the value yet and
175        /// `shep reload <name>` is what promotes it. A client rendering
176        /// this says so, the same rule [`SheepApplied::pending`] carries.
177        pending: bool,
178        /// A `cwd`, `script`, `out_file` or `err_file` that looks wrong on
179        /// disk, `None` for every other field and for one of these four
180        /// that looks fine.
181        ///
182        /// Advisory, not a second way to say no: the write above still
183        /// landed. `normalize` cannot see the filesystem (a daemon and a
184        /// CLI normalizing the same config may run as different users), so
185        /// this is checked once, daemon-side, after the value is already
186        /// accepted, and a directory a deploy script has not created yet is
187        /// a real config the write must not refuse. The window between this
188        /// check and the respawn that actually needs the path is the same
189        /// one `check_log_ancestry`'s own doc comment names for its check
190        /// (`docs/specs/deferred.md`).
191        ///
192        /// Additive: absent rather than `null` on a peer built before this
193        /// field existed, so `PROTOCOL_VERSION` does not move for it.
194        #[serde(default, skip_serializing_if = "Option::is_none")]
195        warning: Option<String>,
196    },
197    /// Answer to `DogSheepSettings`: one dog's table from every sheep
198    /// carrying one.
199    ///
200    /// Empty rather than absent for a sheep naming no table, and empty
201    /// rather than [`RpcErrorCode::NotFound`] for a dog nobody names: a
202    /// dog asks this on its own name, which is never missing the way a
203    /// sheep selector can be.
204    ///
205    /// A table's values never print in a `{:?}` of this reply the way they
206    /// never print in [`AppConfig`]'s own (IR-41): [`DogTable`]'s manual
207    /// `Debug` carries that redaction wherever it rides.
208    DogSheepSettings {
209        /// Sheep name to that sheep's table for this dog.
210        tables: BTreeMap<String, DogTable>,
211    },
212    /// Answer to `SetSheepDogSettings`: which sheep and dog moved.
213    ///
214    /// Names only, like [`Self::SheepEnvSet`] and [`Self::DogConfigSet`]:
215    /// the table just written is the caller's own, so echoing it back
216    /// would put it on the wire a second time for no reader (IR-41).
217    SheepDogSettingsSet {
218        /// The sheep.
219        name: String,
220        /// The dog whose table moved.
221        dog: String,
222    },
223    /// Answer to `SetDogConfig`: the section was written and the topic
224    /// published.
225    DogConfigSet {
226        /// The dog.
227        name: String,
228    },
229    /// Answer to [`Request::PutSecrets`]: how many entries were stored.
230    SecretsPut {
231        /// Entry count, after the namespace and environment were replaced.
232        accepted: u32,
233    },
234    /// Answer to `Stop`
235    Stopped(Vec<ProcessInfo>),
236    /// Answer to `Disable`: every instance of each app now disabled, as
237    /// they stand once stopped.
238    Disabled(Vec<ProcessInfo>),
239    /// Answer to `Enable`: every instance of each app that was disabled and
240    /// is now started.
241    ///
242    /// Empty when the selector matched only apps that were not disabled.
243    Enabled(Vec<ProcessInfo>),
244    /// Answer to `Restart`: the sheep that were restarted, one row each.
245    ///
246    /// **When the reply arrives depends on how many sheep matched.** One
247    /// sheep is answered as soon as its respawn is issued. Two or more are
248    /// restarted in dependency order, and the daemon holds each stage until
249    /// the apps a later stage waits on are back, so the reply arrives no
250    /// sooner than the last stage's respawns and the rows are stitched from
251    /// one answer per stage. A client asking for a budget sizes it for the
252    /// whole walk, not for one respawn.
253    Restarted {
254        /// The sheep the restart reached, one row each.
255        ///
256        /// A row is not a promise the process is up. A respawn that could
257        /// not exec is an `errored` row here rather than an entry in
258        /// `refused` below: the sheep was reached and the restart was not
259        /// refused, it is the child that failed.
260        accepted: Vec<ProcessInfo>,
261        /// The apps the walk could not restart, empty when it restarted
262        /// every one it named.
263        ///
264        /// Only a walk fills this. A selector matching one app is refused
265        /// whole, as the `Err` arm, so a client reading a single-target
266        /// restart never sees a row here.
267        refused: Vec<SheepRefusal>,
268    },
269    /// Answer to `Reload`: acceptances, not results.
270    ///
271    /// One instance costs a readiness wait plus a drain, so a clustered app
272    /// outlasts any deadline a client may ask for. Every row is therefore the
273    /// sheep as it stood when its own reload was accepted, and the swaps
274    /// report themselves on the bus (`process.reload`, `process.reloaded`,
275    /// `process.reload_abandoned`). A matched sheep with nothing to replace
276    /// is listed as the no-op success it is.
277    ///
278    /// **When the reply arrives depends on how many sheep matched.** One
279    /// sheep is answered as soon as its reload is accepted. Two or more are
280    /// reloaded in dependency order, and the daemon holds each stage until
281    /// the swaps of the apps a later stage waits on have landed, so the
282    /// reply arrives no sooner than the last stage's acceptance and the rows
283    /// are stitched from one acceptance per stage. A client asking for a
284    /// budget sizes it for the whole walk, not for one acceptance.
285    Reloading {
286        /// The sheep whose reloads were accepted, one row each.
287        accepted: Vec<ProcessInfo>,
288        /// The apps the walk could not reload, empty when it reloaded every
289        /// one it named.
290        ///
291        /// Only a walk fills this. A selector matching one app is refused
292        /// whole, as the `Err` arm, so a client reading a single-target
293        /// reload never sees a row here.
294        refused: Vec<SheepRefusal>,
295    },
296    /// Answer to `Scale`: the app's instances that will remain, one row each,
297    /// ordered by [`sort_flock`]. Every row shares one name, so that is slot
298    /// order with the id breaking a tie.
299    ///
300    /// Scaling down, the departing instances are absent even though their
301    /// kill ladders are still running; they report themselves on the bus as
302    /// `process.delete`.
303    Scaled(Vec<ProcessInfo>),
304    /// Answer to `SetSmit`: every instance of the named sheep, one row each,
305    /// carrying the smit as it now stands.
306    SmitPainted(Vec<ProcessInfo>),
307    /// Answer to `Delete`: ids removed
308    Deleted(Vec<u32>),
309    /// Answer to `Reopen`: every matched sheep, running or not. A sheep with
310    /// no live log pump has nothing to reopen and is reported as a success,
311    /// so this carries the same matches `Describe` would.
312    Reopened(Vec<ProcessInfo>),
313    /// Answer to `Flush`: one row per matched sheep, running or not, exactly
314    /// as [`Self::Reopened`].
315    ///
316    /// One row per sheep, not per file emptied: several sheep can share one
317    /// log path, and the daemon truncates each distinct path once.
318    Flushed(Vec<ProcessInfo>),
319    /// Answer to `Trigger`: one [`ActionReply`] row per matched sheep, rather
320    /// than a flock listing, since `ProcessInfo` has nowhere to hold a reply
321    /// body.
322    Triggered(Vec<ActionReply>),
323    /// Answer to `Signal`: one [`SignalReply`] row per matched sheep.
324    ///
325    /// Not a flock listing: [`ProcessInfo`] has nowhere to hold a per-sheep
326    /// outcome.
327    Signalled(Vec<SignalReply>),
328    /// Answer to `SendLine`: one [`LineReply`] row per matched sheep.
329    SentLine(Vec<LineReply>),
330    /// Answer to `SaveRoll`
331    RollSaved {
332        /// Absolute path of the roll the daemon wrote
333        path: String,
334        /// How many apps that roll records
335        apps: u32,
336    },
337    /// Answer to `Muster`: every sheep of every app the roll restored, not
338    /// only the ones this call spawned.
339    ///
340    /// Assembling a flock that is already assembled starts nothing, so a
341    /// listing of what this call spawned would be indistinguishable from an
342    /// empty roll.
343    Mustered(Vec<ProcessInfo>),
344    /// Answer to `DogConfig`: the dog's own section, rendered back to TOML.
345    ///
346    /// `toml` is [`DogSectionToml`], whose manual `Debug` keeps the webhook
347    /// credentials this text carries out of a `{:?}`-formatted `Response`.
348    DogSection {
349        /// The `[dog.<name>]` table as TOML text, empty when the file has
350        /// no such section
351        toml: DogSectionToml,
352    },
353    /// Answer to `EnableDog`: the dog as it stands now
354    DogStarted(ProcessInfo),
355    /// Answer to `DogStaleness`: this daemon's own handshake record, split
356    /// into the dogs it has given up on and the dogs it is still waiting on.
357    ///
358    /// Two lists because they answer two questions. `stale` is a finding;
359    /// `pending` is a reason to ask again, since a reading taken now would
360    /// be a guess about them.
361    ///
362    /// Names only: two builds differing only in the protocol they speak
363    /// report the same crate version.
364    DogStaleness {
365        /// Dogs this daemon has refused twice: once on the handshake that
366        /// bought them a restart from disk, and again after it. It will not
367        /// restart them a third time.
368        stale: Vec<String>,
369        /// Dogs this daemon is still waiting to hear a final answer from: one
370        /// whose restart is in flight, or one it supervises that has not
371        /// handshook yet. Neither stale nor known healthy.
372        pending: Vec<String>,
373    },
374    /// Answer to `HandoverFitness`: `None` when the whole flock can be
375    /// carried across a daemon handover, and otherwise the sentence saying
376    /// which sheep cannot be and why.
377    ///
378    /// A rendered sentence rather than a structured reason: the set of things
379    /// a handover cannot carry keeps changing, and the client only prints it.
380    HandoverFitness {
381        /// Why the flock cannot be handed over in place, or `None` when it
382        /// can.
383        refusal: Option<String>,
384    },
385    /// Answer to `Subscribe`
386    Subscribed,
387    /// Answer to `KillDaemon`
388    ShuttingDown,
389}
390
391#[cfg(test)]
392mod tests {
393    use super::super::process::sample_info;
394    use super::super::{
395        ActionOutcome, DogSource, ExitInfo, Lamb, LineOutcome, Reply, Request, RpcError,
396        RpcErrorCode, SignalOutcome,
397    };
398    use super::*;
399    use crate::config::AppConfig;
400    use crate::status::ProcStatus;
401
402    /// The reply carries key names and never a value.
403    #[test]
404    fn sheep_env_batch_response_wire_v8() {
405        let response = Response::SheepEnvBatch {
406            name: "web".to_string(),
407            set: vec!["A".to_string()],
408            unchanged: vec!["B".to_string()],
409            collisions: Vec::new(),
410        };
411        let json = serde_json::to_string(&response).unwrap();
412        assert_eq!(
413            json,
414            r#"{"kind":"sheep_env_batch","data":{"name":"web","set":["A"],"unchanged":["B"],"collisions":[]}}"#
415        );
416        assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), response);
417    }
418
419    #[test]
420    fn reply_wire_snapshots() {
421        let replies = vec![
422            Reply {
423                id: 1,
424                result: Ok(Response::Pong),
425            },
426            Reply {
427                id: 2,
428                result: Ok(Response::Flock(vec![sample_info()])),
429            },
430            Reply {
431                id: 3,
432                result: Err(RpcError {
433                    code: RpcErrorCode::NotFound,
434                    message: "no sheep matches `web`".to_string(),
435                    daemon_version: None,
436                }),
437            },
438            // `ActionReply` is not a `ProcessInfo`. `Replied` is the
439            // struct-shaped `ActionOutcome` variant and so the one worth
440            // pinning.
441            Reply {
442                id: 4,
443                result: Ok(Response::Triggered(vec![ActionReply {
444                    id: 3,
445                    name: "web".to_string(),
446                    outcome: ActionOutcome::Replied {
447                        body: "ok".to_string(),
448                    },
449                }])),
450            },
451            // The only struct-shaped `Response` variant; every other one is
452            // a newtype over a `Vec` or a unit, both proven above.
453            Reply {
454                id: 5,
455                result: Ok(Response::RollSaved {
456                    path: "/home/ada/.shep/flock.json".to_string(),
457                    apps: 2,
458                }),
459            },
460            // The present `dog` marker; `sample_info()` pins the absent one.
461            // `Adopted` because it is the variant carrying a payload.
462            Reply {
463                id: 6,
464                result: Ok(Response::Flock(vec![ProcessInfo {
465                    id: 7,
466                    name: "otel".to_string(),
467                    dog: Some(DogSource::Adopted {
468                        path: "/usr/local/bin/shep-otel".to_string(),
469                        channel: false,
470                    }),
471                    ..sample_info()
472                }])),
473            },
474            // The section crosses the wire as text, never a typed structure.
475            Reply {
476                id: 7,
477                result: Ok(Response::DogSection {
478                    toml: "port = 9615\n".to_string().into(),
479                }),
480            },
481            // The only `Response` variant carrying a bare `ProcessInfo`
482            // rather than a `Vec`: `enable` starts exactly one dog.
483            Reply {
484                id: 8,
485                result: Ok(Response::DogStarted(ProcessInfo {
486                    id: 4,
487                    name: "metrics".to_string(),
488                    dog: Some(DogSource::BuiltIn),
489                    ..sample_info()
490                })),
491            },
492            // Each row below carries the smallest body that shows its wire
493            // shape: the tag is what is being pinned. `Deleted` is a
494            // `Vec<u32>`; `Subscribed` and `ShuttingDown` carry nothing.
495            Reply {
496                id: 9,
497                result: Ok(Response::Described(vec![])),
498            },
499            Reply {
500                id: 10,
501                result: Ok(Response::Started(vec![])),
502            },
503            Reply {
504                id: 11,
505                result: Ok(Response::Stopped(vec![])),
506            },
507            // Both halves populated, as the row below: `refused` is the one
508            // field on either variant a walk fills and a single-target
509            // request never does.
510            Reply {
511                id: 12,
512                result: Ok(Response::Restarted {
513                    accepted: vec![],
514                    refused: vec![SheepRefusal::new(
515                        "db",
516                        "selector matched no registered sheep",
517                    )],
518                }),
519            },
520            Reply {
521                id: 13,
522                result: Ok(Response::Reloading {
523                    accepted: vec![],
524                    refused: vec![SheepRefusal::new("db", "db is already being reloaded")],
525                }),
526            },
527            Reply {
528                id: 14,
529                result: Ok(Response::Deleted(vec![7, 8])),
530            },
531            Reply {
532                id: 15,
533                result: Ok(Response::Reopened(vec![])),
534            },
535            Reply {
536                id: 16,
537                result: Ok(Response::Flushed(vec![])),
538            },
539            Reply {
540                id: 17,
541                result: Ok(Response::Mustered(vec![])),
542            },
543            Reply {
544                id: 18,
545                result: Ok(Response::Subscribed),
546            },
547            Reply {
548                id: 19,
549                result: Ok(Response::ShuttingDown),
550            },
551            // `Signalled`, mirroring the `Triggered` row: one row per
552            // `SignalOutcome` variant, so no tag is left unproven.
553            Reply {
554                id: 20,
555                result: Ok(Response::Signalled(vec![
556                    SignalReply {
557                        id: 1,
558                        name: "web".to_string(),
559                        outcome: SignalOutcome::Delivered,
560                    },
561                    SignalReply {
562                        id: 2,
563                        name: "web".to_string(),
564                        outcome: SignalOutcome::NotRunning,
565                    },
566                    SignalReply {
567                        id: 3,
568                        name: "api".to_string(),
569                        outcome: SignalOutcome::Failed {
570                            reason: "no such process".to_string(),
571                        },
572                    },
573                ])),
574            },
575            Reply {
576                id: 21,
577                result: Ok(Response::Scaled(vec![sample_info()])),
578            },
579            // `SentLine`, mirroring the `Signalled` row: one row per
580            // `LineOutcome` variant.
581            Reply {
582                id: 22,
583                result: Ok(Response::SentLine(vec![
584                    LineReply {
585                        id: 1,
586                        name: "repl".to_string(),
587                        outcome: LineOutcome::Sent,
588                    },
589                    LineReply {
590                        id: 2,
591                        name: "web".to_string(),
592                        outcome: LineOutcome::NoStdin,
593                    },
594                    LineReply {
595                        id: 3,
596                        name: "stuck".to_string(),
597                        outcome: LineOutcome::NotWritten {
598                            reason: "the app did not read its stdin within 2s".to_string(),
599                        },
600                    },
601                ])),
602            },
603            // A walked lamb tree; every other row pins the `null` shape.
604            Reply {
605                id: 23,
606                result: Ok(Response::Described(vec![
607                    ProcessInfo::builder(3, "web", ProcStatus::Online)
608                        .pid(Some(4242))
609                        .lambs(Some(vec![
610                            Lamb::new(4243, "node"),
611                            Lamb::new(4244, "sh").with_label("worker 1"),
612                        ]))
613                        .build(),
614                ])),
615            },
616            // The killed-by-signal shape of `last_exit`; every row above pins
617            // the exited-normally one. `SIGTERM`'s raw number, since this
618            // crate carries no name for it.
619            Reply {
620                id: 24,
621                result: Ok(Response::Flock(vec![
622                    ProcessInfo::builder(5, "worker", ProcStatus::Stopped)
623                        .restarts(1)
624                        .last_exit(Some(ExitInfo {
625                            code: None,
626                            signal: Some(15),
627                        }))
628                        .build(),
629                ])),
630            },
631            // The one row that pins a smit on the wire; `sample_info()` carries
632            // none.
633            Reply {
634                id: 25,
635                result: Ok(Response::SmitPainted(vec![
636                    ProcessInfo::builder(3, "web", ProcStatus::Online)
637                        .pid(Some(4242))
638                        .smit(Some("\u{25b2} main@a1b2c3".to_string()))
639                        .build(),
640                ])),
641            },
642            // A sheep drifting in one field and a sheep drifting in several.
643            // `env` is one of them: the name travels and the value never does.
644            Reply {
645                id: 26,
646                result: Ok(Response::Drifted(vec![
647                    SheepDrift::new("web", vec!["cwd".to_string()]),
648                    SheepDrift::new(
649                        "api",
650                        vec!["args".to_string(), "env".to_string(), "script".to_string()],
651                    ),
652                ])),
653            },
654            // The present shape of `instance`; every row above pins its absence.
655            Reply {
656                id: 27,
657                result: Ok(Response::Flock(vec![
658                    ProcessInfo::builder(9, "web", ProcStatus::Online)
659                        .pid(Some(5150))
660                        .instance(Some(2))
661                        .build(),
662                ])),
663            },
664            // Both shapes of the handover answer; the difference between them
665            // is a `null`.
666            Reply {
667                id: 28,
668                result: Ok(Response::HandoverFitness { refusal: None }),
669            },
670            Reply {
671                id: 29,
672                result: Ok(Response::HandoverFitness {
673                    refusal: Some("sheep 'web' has a shepherd channel".to_string()),
674                }),
675            },
676            // Both lists non-empty and different: the two carry the same wire
677            // shape.
678            Reply {
679                id: 30,
680                result: Ok(Response::DogStaleness {
681                    stale: vec!["metrics".to_string()],
682                    pending: vec!["bark".to_string()],
683                }),
684            },
685            // A dog whose process is up and which has never answered this
686            // shepherd. `dog_stale: false` is the silence still being waited
687            // out; the row below is the one it has given up on.
688            Reply {
689                id: 31,
690                result: Ok(Response::Flock(vec![
691                    ProcessInfo::builder(10, "log-rotate", ProcStatus::Online)
692                        .pid(Some(208_341))
693                        .dog(Some(DogSource::Adopted {
694                            path: "/usr/local/bin/shep-log-rotate".to_string(),
695                            channel: false,
696                        }))
697                        .handshook(Some(false))
698                        .dog_stale(Some(false))
699                        .build(),
700                ])),
701            },
702            Reply {
703                id: 32,
704                result: Ok(Response::Flock(vec![
705                    ProcessInfo::builder(10, "log-rotate", ProcStatus::Online)
706                        .pid(Some(208_341))
707                        .dog(Some(DogSource::Adopted {
708                            path: "/usr/local/bin/shep-log-rotate".to_string(),
709                            channel: false,
710                        }))
711                        .handshook(Some(false))
712                        .dog_stale(Some(true))
713                        .build(),
714                ])),
715            },
716            // Three entries, one per shape a load produces: applied, pending,
717            // refused. `env` is a pending name on purpose: the name travels
718            // and the value never does.
719            Reply {
720                id: 32,
721                result: Ok(Response::Applied(vec![
722                    SheepApplied::new("web", vec!["max_memory".to_string()], Vec::new(), None),
723                    SheepApplied::new(
724                        "api",
725                        Vec::new(),
726                        vec!["args".to_string(), "env".to_string()],
727                        None,
728                    ),
729                    SheepApplied::new(
730                        "worker",
731                        Vec::new(),
732                        Vec::new(),
733                        Some("worker is not registered".to_string()),
734                    ),
735                ])),
736            },
737            // `Added`'s tag, all a fixture can prove for a `Vec<ProcessInfo>`
738            // variant. Down here because every id in this vector is
739            // hand-written.
740            Reply {
741                id: 33,
742                result: Ok(Response::Added(vec![])),
743            },
744            // The config pane's answer, and the row that proves its whole
745            // security property: `env` serializes as an empty object while
746            // `env_keys` names the key beside it, so an out-of-tree reader
747            // learns here that a value never travels (IR-41).
748            Reply {
749                id: 34,
750                result: Ok(Response::SheepConfig(Box::new(SheepConfigView::new(
751                    {
752                        let mut config = AppConfig::minimal("web", "./srv");
753                        config
754                            .env
755                            .insert("DATABASE_URL".to_string(), "postgres://x".to_string());
756                        config
757                    },
758                    vec!["max_restarts".to_string()],
759                    vec!["env".to_string()],
760                )))),
761            },
762            // The three acknowledgements. None echoes what was written:
763            // `SheepEnvSet` names the key and not its value, for the reason
764            // the row above pins, `SheepFieldSet` does the same and adds
765            // the one bit the caller cannot derive, and `DogConfigSet`
766            // names the dog and not the section.
767            Reply {
768                id: 35,
769                result: Ok(Response::SheepEnvSet {
770                    name: "web".to_string(),
771                    key: "DATABASE_URL".to_string(),
772                }),
773            },
774            // `pending` pinned `true`, because `false` is the value a reader
775            // that dropped the field entirely would decode by accident, and
776            // the two answers send an operator to different places: one
777            // says the change is in force, the other says to reload.
778            Reply {
779                id: 36,
780                result: Ok(Response::SheepFieldSet {
781                    name: "web".to_string(),
782                    key: "script".to_string(),
783                    pending: true,
784                    // `None` on purpose: this is the row every earlier
785                    // version's fixture already pinned, and `warning` must
786                    // stay invisible on the wire for that value or the
787                    // additive claim above is false.
788                    warning: None,
789                }),
790            },
791            Reply {
792                id: 37,
793                result: Ok(Response::DogConfigSet {
794                    name: "bark".to_string(),
795                }),
796            },
797            // A count and not the entries: a dog already knows what it
798            // pushed, and echoing the map back would put every value it
799            // sent on the wire a second time for no reader (IR-41).
800            Reply {
801                id: 38,
802                result: Ok(Response::SecretsPut { accepted: 2 }),
803            },
804            // Key names only, on all three lists, and none empty here on
805            // purpose: an empty `Vec` serializes the same whether it holds
806            // strings or something else, so a reader that guessed the
807            // element type wrong would still pass against an empty-list
808            // fixture. `collisions` also proves a forced write can name a
809            // key in both `set` and `collisions` at once.
810            Reply {
811                id: 39,
812                result: Ok(Response::SheepEnvBatch {
813                    name: "web".to_string(),
814                    set: vec!["DATABASE_URL".to_string(), "API_KEY".to_string()],
815                    unchanged: vec!["LOG_LEVEL".to_string()],
816                    collisions: vec!["API_KEY".to_string()],
817                }),
818            },
819            // Both halves of the same reply: a machine that has been read
820            // and one that has not been read for long enough yet. The rates
821            // are what separate them, and they are `null` rather than `0`
822            // in the second, which is the whole distinction the renderer
823            // spells as `-`.
824            Reply {
825                id: 40,
826                result: Ok(Response::HostUsage(Some(HostUsage {
827                    cpu_percent: Some(11.5),
828                    memory_used_bytes: 39_963_869_184,
829                    memory_total_bytes: 51_539_607_552,
830                    disk_bytes_per_second: Some((1_258_291, 491_520)),
831                    network_bytes_per_second: Some((24_594, 9_260)),
832                }))),
833            },
834            Reply {
835                id: 41,
836                result: Ok(Response::HostUsage(Some(HostUsage {
837                    cpu_percent: None,
838                    memory_used_bytes: 39_963_869_184,
839                    memory_total_bytes: 51_539_607_552,
840                    disk_bytes_per_second: None,
841                    network_bytes_per_second: None,
842                }))),
843            },
844            // A platform `sysinfo` cannot read at all, which is neither of
845            // the two above.
846            Reply {
847                id: 42,
848                result: Ok(Response::HostUsage(None)),
849            },
850            // Two sheep, one table each, so this row proves a dog reads
851            // more than one sheep's table from a single answer. The table
852            // itself repeats `SetSheepDogSettings`'s own two keys, one
853            // nested, so both rides through this enum agree on the shape.
854            Reply {
855                id: 43,
856                result: Ok(Response::DogSheepSettings {
857                    tables: BTreeMap::from([
858                        (
859                            "web".to_string(),
860                            DogTable::from(serde_json::Map::from_iter([
861                                ("concurrency".to_string(), serde_json::json!(2)),
862                                (
863                                    "hours".to_string(),
864                                    serde_json::json!({ "start": "09:00:00" }),
865                                ),
866                            ])),
867                        ),
868                        (
869                            "worker".to_string(),
870                            DogTable::from(serde_json::Map::from_iter([(
871                                "concurrency".to_string(),
872                                serde_json::json!(1),
873                            )])),
874                        ),
875                    ]),
876                }),
877            },
878            Reply {
879                id: 44,
880                result: Ok(Response::SheepDogSettingsSet {
881                    name: "web".to_string(),
882                    dog: "jobs".to_string(),
883                }),
884            },
885        ];
886        insta::assert_json_snapshot!("reply_wire_v11", replies);
887    }
888
889    /// The additive claim on `SheepFieldSet::warning` has three halves and
890    /// the reply fixture pins only the first. `None` staying off the wire is
891    /// what makes the field additive at all; `Some` surviving a round trip is
892    /// what makes it useful; and a payload with no `warning` key reading back
893    /// as `None` is what lets a daemon built before the field answer a client
894    /// built after it.
895    ///
896    /// The third pins the behaviour, not the attribute. Measured by removing
897    /// `#[serde(default)]` and re-running: it still passes, because serde
898    /// already reads a missing `Option` field as `None` without being asked.
899    /// So the attribute is belt-and-braces and this test would not notice its
900    /// removal. What it does notice is the field being renamed, retyped, or
901    /// made required, which is what would actually break an older peer.
902    #[test]
903    fn the_set_field_warning_is_additive_in_both_directions() {
904        let quiet = Response::SheepFieldSet {
905            name: "web".to_string(),
906            key: "script".to_string(),
907            pending: true,
908            warning: None,
909        };
910        let json = serde_json::to_string(&quiet).unwrap();
911        assert!(
912            !json.contains("warning"),
913            "a `None` warning must not reach the wire at all: {json}"
914        );
915
916        let loud = Response::SheepFieldSet {
917            name: "web".to_string(),
918            key: "cwd".to_string(),
919            pending: true,
920            warning: Some("/srv/app does not exist yet".to_string()),
921        };
922        let json = serde_json::to_string(&loud).unwrap();
923        let back: Response = serde_json::from_str(&json).unwrap();
924        assert_eq!(back, loud, "a warning must survive the round trip: {json}");
925
926        let older =
927            r#"{"kind":"sheep_field_set","data":{"name":"web","key":"script","pending":true}}"#;
928        let back: Response = serde_json::from_str(older).unwrap();
929        assert_eq!(
930            back,
931            Response::SheepFieldSet {
932                name: "web".to_string(),
933                key: "script".to_string(),
934                pending: true,
935                warning: None,
936            },
937            "a peer that predates the field must still deserialize"
938        );
939    }
940
941    #[test]
942    fn save_roll_serializes_snake_case_with_its_payload_under_data() {
943        assert_eq!(
944            serde_json::to_string(&Request::SaveRoll).unwrap(),
945            r#"{"kind":"save_roll"}"#
946        );
947        let reply = Response::RollSaved {
948            path: "/tmp/flock.json".to_string(),
949            apps: 3,
950        };
951        let wire = r#"{"kind":"roll_saved","data":{"path":"/tmp/flock.json","apps":3}}"#;
952        assert_eq!(serde_json::to_string(&reply).unwrap(), wire);
953        assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), reply);
954    }
955
956    /// The listing is empty on purpose: `reply_wire_snapshots` pins the row
957    /// field by field.
958    #[test]
959    fn muster_serializes_snake_case_with_its_listing_under_data() {
960        assert_eq!(
961            serde_json::to_string(&Request::Muster).unwrap(),
962            r#"{"kind":"muster"}"#
963        );
964        let reply = Response::Mustered(Vec::new());
965        let wire = r#"{"kind":"mustered","data":[]}"#;
966        assert_eq!(serde_json::to_string(&reply).unwrap(), wire);
967        assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), reply);
968    }
969}