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