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 `Restart`: the sheep that were restarted, one row each.
237    ///
238    /// **When the reply arrives depends on how many sheep matched.** One
239    /// sheep is answered as soon as its respawn is issued. Two or more are
240    /// restarted in dependency order, and the daemon holds each stage until
241    /// the apps a later stage waits on are back, so the reply arrives no
242    /// sooner than the last stage's respawns and the rows are stitched from
243    /// one answer per stage. A client asking for a budget sizes it for the
244    /// whole walk, not for one respawn.
245    Restarted {
246        /// The sheep the restart reached, one row each.
247        ///
248        /// A row is not a promise the process is up. A respawn that could
249        /// not exec is an `errored` row here rather than an entry in
250        /// `refused` below: the sheep was reached and the restart was not
251        /// refused, it is the child that failed.
252        accepted: Vec<ProcessInfo>,
253        /// The apps the walk could not restart, empty when it restarted
254        /// every one it named.
255        ///
256        /// Only a walk fills this. A selector matching one app is refused
257        /// whole, as the `Err` arm, so a client reading a single-target
258        /// restart never sees a row here.
259        refused: Vec<SheepRefusal>,
260    },
261    /// Answer to `Reload`: acceptances, not results.
262    ///
263    /// One instance costs a readiness wait plus a drain, so a clustered app
264    /// outlasts any deadline a client may ask for. Every row is therefore the
265    /// sheep as it stood when its own reload was accepted, and the swaps
266    /// report themselves on the bus (`process.reload`, `process.reloaded`,
267    /// `process.reload_abandoned`). A matched sheep with nothing to replace
268    /// is listed as the no-op success it is.
269    ///
270    /// **When the reply arrives depends on how many sheep matched.** One
271    /// sheep is answered as soon as its reload is accepted. Two or more are
272    /// reloaded in dependency order, and the daemon holds each stage until
273    /// the swaps of the apps a later stage waits on have landed, so the
274    /// reply arrives no sooner than the last stage's acceptance and the rows
275    /// are stitched from one acceptance per stage. A client asking for a
276    /// budget sizes it for the whole walk, not for one acceptance.
277    Reloading {
278        /// The sheep whose reloads were accepted, one row each.
279        accepted: Vec<ProcessInfo>,
280        /// The apps the walk could not reload, empty when it reloaded every
281        /// one it named.
282        ///
283        /// Only a walk fills this. A selector matching one app is refused
284        /// whole, as the `Err` arm, so a client reading a single-target
285        /// reload never sees a row here.
286        refused: Vec<SheepRefusal>,
287    },
288    /// Answer to `Scale`: the app's instances that will remain, one row each,
289    /// ordered by [`sort_flock`]. Every row shares one name, so that is slot
290    /// order with the id breaking a tie.
291    ///
292    /// Scaling down, the departing instances are absent even though their
293    /// kill ladders are still running; they report themselves on the bus as
294    /// `process.delete`.
295    Scaled(Vec<ProcessInfo>),
296    /// Answer to `SetSmit`: every instance of the named sheep, one row each,
297    /// carrying the smit as it now stands.
298    SmitPainted(Vec<ProcessInfo>),
299    /// Answer to `Delete`: ids removed
300    Deleted(Vec<u32>),
301    /// Answer to `Reopen`: every matched sheep, running or not. A sheep with
302    /// no live log pump has nothing to reopen and is reported as a success,
303    /// so this carries the same matches `Describe` would.
304    Reopened(Vec<ProcessInfo>),
305    /// Answer to `Flush`: one row per matched sheep, running or not, exactly
306    /// as [`Self::Reopened`].
307    ///
308    /// One row per sheep, not per file emptied: several sheep can share one
309    /// log path, and the daemon truncates each distinct path once.
310    Flushed(Vec<ProcessInfo>),
311    /// Answer to `Trigger`: one [`ActionReply`] row per matched sheep, rather
312    /// than a flock listing, since `ProcessInfo` has nowhere to hold a reply
313    /// body.
314    Triggered(Vec<ActionReply>),
315    /// Answer to `Signal`: one [`SignalReply`] row per matched sheep.
316    ///
317    /// Not a flock listing: [`ProcessInfo`] has nowhere to hold a per-sheep
318    /// outcome.
319    Signalled(Vec<SignalReply>),
320    /// Answer to `SendLine`: one [`LineReply`] row per matched sheep.
321    SentLine(Vec<LineReply>),
322    /// Answer to `SaveRoll`
323    RollSaved {
324        /// Absolute path of the roll the daemon wrote
325        path: String,
326        /// How many apps that roll records
327        apps: u32,
328    },
329    /// Answer to `Muster`: every sheep of every app the roll restored, not
330    /// only the ones this call spawned.
331    ///
332    /// Assembling a flock that is already assembled starts nothing, so a
333    /// listing of what this call spawned would be indistinguishable from an
334    /// empty roll.
335    Mustered(Vec<ProcessInfo>),
336    /// Answer to `DogConfig`: the dog's own section, rendered back to TOML.
337    ///
338    /// `toml` is [`DogSectionToml`], whose manual `Debug` keeps the webhook
339    /// credentials this text carries out of a `{:?}`-formatted `Response`.
340    DogSection {
341        /// The `[dog.<name>]` table as TOML text, empty when the file has
342        /// no such section
343        toml: DogSectionToml,
344    },
345    /// Answer to `EnableDog`: the dog as it stands now
346    DogStarted(ProcessInfo),
347    /// Answer to `DogStaleness`: this daemon's own handshake record, split
348    /// into the dogs it has given up on and the dogs it is still waiting on.
349    ///
350    /// Two lists because they answer two questions. `stale` is a finding;
351    /// `pending` is a reason to ask again, since a reading taken now would
352    /// be a guess about them.
353    ///
354    /// Names only: two builds differing only in the protocol they speak
355    /// report the same crate version.
356    DogStaleness {
357        /// Dogs this daemon has refused twice: once on the handshake that
358        /// bought them a restart from disk, and again after it. It will not
359        /// restart them a third time.
360        stale: Vec<String>,
361        /// Dogs this daemon is still waiting to hear a final answer from: one
362        /// whose restart is in flight, or one it supervises that has not
363        /// handshook yet. Neither stale nor known healthy.
364        pending: Vec<String>,
365    },
366    /// Answer to `HandoverFitness`: `None` when the whole flock can be
367    /// carried across a daemon handover, and otherwise the sentence saying
368    /// which sheep cannot be and why.
369    ///
370    /// A rendered sentence rather than a structured reason: the set of things
371    /// a handover cannot carry keeps changing, and the client only prints it.
372    HandoverFitness {
373        /// Why the flock cannot be handed over in place, or `None` when it
374        /// can.
375        refusal: Option<String>,
376    },
377    /// Answer to `Subscribe`
378    Subscribed,
379    /// Answer to `KillDaemon`
380    ShuttingDown,
381}
382
383#[cfg(test)]
384mod tests {
385    use super::super::process::sample_info;
386    use super::super::{
387        ActionOutcome, DogSource, ExitInfo, Lamb, LineOutcome, Reply, Request, RpcError,
388        RpcErrorCode, SignalOutcome,
389    };
390    use super::*;
391    use crate::config::AppConfig;
392    use crate::status::ProcStatus;
393
394    /// The reply carries key names and never a value.
395    #[test]
396    fn sheep_env_batch_response_wire_v8() {
397        let response = Response::SheepEnvBatch {
398            name: "web".to_string(),
399            set: vec!["A".to_string()],
400            unchanged: vec!["B".to_string()],
401            collisions: Vec::new(),
402        };
403        let json = serde_json::to_string(&response).unwrap();
404        assert_eq!(
405            json,
406            r#"{"kind":"sheep_env_batch","data":{"name":"web","set":["A"],"unchanged":["B"],"collisions":[]}}"#
407        );
408        assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), response);
409    }
410
411    #[test]
412    fn reply_wire_snapshots() {
413        let replies = vec![
414            Reply {
415                id: 1,
416                result: Ok(Response::Pong),
417            },
418            Reply {
419                id: 2,
420                result: Ok(Response::Flock(vec![sample_info()])),
421            },
422            Reply {
423                id: 3,
424                result: Err(RpcError {
425                    code: RpcErrorCode::NotFound,
426                    message: "no sheep matches `web`".to_string(),
427                    daemon_version: None,
428                }),
429            },
430            // `ActionReply` is not a `ProcessInfo`. `Replied` is the
431            // struct-shaped `ActionOutcome` variant and so the one worth
432            // pinning.
433            Reply {
434                id: 4,
435                result: Ok(Response::Triggered(vec![ActionReply {
436                    id: 3,
437                    name: "web".to_string(),
438                    outcome: ActionOutcome::Replied {
439                        body: "ok".to_string(),
440                    },
441                }])),
442            },
443            // The only struct-shaped `Response` variant; every other one is
444            // a newtype over a `Vec` or a unit, both proven above.
445            Reply {
446                id: 5,
447                result: Ok(Response::RollSaved {
448                    path: "/home/ada/.shep/flock.json".to_string(),
449                    apps: 2,
450                }),
451            },
452            // The present `dog` marker; `sample_info()` pins the absent one.
453            // `Adopted` because it is the variant carrying a payload.
454            Reply {
455                id: 6,
456                result: Ok(Response::Flock(vec![ProcessInfo {
457                    id: 7,
458                    name: "otel".to_string(),
459                    dog: Some(DogSource::Adopted {
460                        path: "/usr/local/bin/shep-otel".to_string(),
461                        channel: false,
462                    }),
463                    ..sample_info()
464                }])),
465            },
466            // The section crosses the wire as text, never a typed structure.
467            Reply {
468                id: 7,
469                result: Ok(Response::DogSection {
470                    toml: "port = 9615\n".to_string().into(),
471                }),
472            },
473            // The only `Response` variant carrying a bare `ProcessInfo`
474            // rather than a `Vec`: `enable` starts exactly one dog.
475            Reply {
476                id: 8,
477                result: Ok(Response::DogStarted(ProcessInfo {
478                    id: 4,
479                    name: "metrics".to_string(),
480                    dog: Some(DogSource::BuiltIn),
481                    ..sample_info()
482                })),
483            },
484            // Each row below carries the smallest body that shows its wire
485            // shape: the tag is what is being pinned. `Deleted` is a
486            // `Vec<u32>`; `Subscribed` and `ShuttingDown` carry nothing.
487            Reply {
488                id: 9,
489                result: Ok(Response::Described(vec![])),
490            },
491            Reply {
492                id: 10,
493                result: Ok(Response::Started(vec![])),
494            },
495            Reply {
496                id: 11,
497                result: Ok(Response::Stopped(vec![])),
498            },
499            // Both halves populated, as the row below: `refused` is the one
500            // field on either variant a walk fills and a single-target
501            // request never does.
502            Reply {
503                id: 12,
504                result: Ok(Response::Restarted {
505                    accepted: vec![],
506                    refused: vec![SheepRefusal::new(
507                        "db",
508                        "selector matched no registered sheep",
509                    )],
510                }),
511            },
512            Reply {
513                id: 13,
514                result: Ok(Response::Reloading {
515                    accepted: vec![],
516                    refused: vec![SheepRefusal::new("db", "db is already being reloaded")],
517                }),
518            },
519            Reply {
520                id: 14,
521                result: Ok(Response::Deleted(vec![7, 8])),
522            },
523            Reply {
524                id: 15,
525                result: Ok(Response::Reopened(vec![])),
526            },
527            Reply {
528                id: 16,
529                result: Ok(Response::Flushed(vec![])),
530            },
531            Reply {
532                id: 17,
533                result: Ok(Response::Mustered(vec![])),
534            },
535            Reply {
536                id: 18,
537                result: Ok(Response::Subscribed),
538            },
539            Reply {
540                id: 19,
541                result: Ok(Response::ShuttingDown),
542            },
543            // `Signalled`, mirroring the `Triggered` row: one row per
544            // `SignalOutcome` variant, so no tag is left unproven.
545            Reply {
546                id: 20,
547                result: Ok(Response::Signalled(vec![
548                    SignalReply {
549                        id: 1,
550                        name: "web".to_string(),
551                        outcome: SignalOutcome::Delivered,
552                    },
553                    SignalReply {
554                        id: 2,
555                        name: "web".to_string(),
556                        outcome: SignalOutcome::NotRunning,
557                    },
558                    SignalReply {
559                        id: 3,
560                        name: "api".to_string(),
561                        outcome: SignalOutcome::Failed {
562                            reason: "no such process".to_string(),
563                        },
564                    },
565                ])),
566            },
567            Reply {
568                id: 21,
569                result: Ok(Response::Scaled(vec![sample_info()])),
570            },
571            // `SentLine`, mirroring the `Signalled` row: one row per
572            // `LineOutcome` variant.
573            Reply {
574                id: 22,
575                result: Ok(Response::SentLine(vec![
576                    LineReply {
577                        id: 1,
578                        name: "repl".to_string(),
579                        outcome: LineOutcome::Sent,
580                    },
581                    LineReply {
582                        id: 2,
583                        name: "web".to_string(),
584                        outcome: LineOutcome::NoStdin,
585                    },
586                    LineReply {
587                        id: 3,
588                        name: "stuck".to_string(),
589                        outcome: LineOutcome::NotWritten {
590                            reason: "the app did not read its stdin within 2s".to_string(),
591                        },
592                    },
593                ])),
594            },
595            // A walked lamb tree; every other row pins the `null` shape.
596            Reply {
597                id: 23,
598                result: Ok(Response::Described(vec![
599                    ProcessInfo::builder(3, "web", ProcStatus::Online)
600                        .pid(Some(4242))
601                        .lambs(Some(vec![
602                            Lamb::new(4243, "node"),
603                            Lamb::new(4244, "sh").with_label("worker 1"),
604                        ]))
605                        .build(),
606                ])),
607            },
608            // The killed-by-signal shape of `last_exit`; every row above pins
609            // the exited-normally one. `SIGTERM`'s raw number, since this
610            // crate carries no name for it.
611            Reply {
612                id: 24,
613                result: Ok(Response::Flock(vec![
614                    ProcessInfo::builder(5, "worker", ProcStatus::Stopped)
615                        .restarts(1)
616                        .last_exit(Some(ExitInfo {
617                            code: None,
618                            signal: Some(15),
619                        }))
620                        .build(),
621                ])),
622            },
623            // The one row that pins a smit on the wire; `sample_info()` carries
624            // none.
625            Reply {
626                id: 25,
627                result: Ok(Response::SmitPainted(vec![
628                    ProcessInfo::builder(3, "web", ProcStatus::Online)
629                        .pid(Some(4242))
630                        .smit(Some("\u{25b2} main@a1b2c3".to_string()))
631                        .build(),
632                ])),
633            },
634            // A sheep drifting in one field and a sheep drifting in several.
635            // `env` is one of them: the name travels and the value never does.
636            Reply {
637                id: 26,
638                result: Ok(Response::Drifted(vec![
639                    SheepDrift::new("web", vec!["cwd".to_string()]),
640                    SheepDrift::new(
641                        "api",
642                        vec!["args".to_string(), "env".to_string(), "script".to_string()],
643                    ),
644                ])),
645            },
646            // The present shape of `instance`; every row above pins its absence.
647            Reply {
648                id: 27,
649                result: Ok(Response::Flock(vec![
650                    ProcessInfo::builder(9, "web", ProcStatus::Online)
651                        .pid(Some(5150))
652                        .instance(Some(2))
653                        .build(),
654                ])),
655            },
656            // Both shapes of the handover answer; the difference between them
657            // is a `null`.
658            Reply {
659                id: 28,
660                result: Ok(Response::HandoverFitness { refusal: None }),
661            },
662            Reply {
663                id: 29,
664                result: Ok(Response::HandoverFitness {
665                    refusal: Some("sheep 'web' has a shepherd channel".to_string()),
666                }),
667            },
668            // Both lists non-empty and different: the two carry the same wire
669            // shape.
670            Reply {
671                id: 30,
672                result: Ok(Response::DogStaleness {
673                    stale: vec!["metrics".to_string()],
674                    pending: vec!["bark".to_string()],
675                }),
676            },
677            // A dog whose process is up and which has never answered this
678            // shepherd. `dog_stale: false` is the silence still being waited
679            // out; the row below is the one it has given up on.
680            Reply {
681                id: 31,
682                result: Ok(Response::Flock(vec![
683                    ProcessInfo::builder(10, "log-rotate", ProcStatus::Online)
684                        .pid(Some(208_341))
685                        .dog(Some(DogSource::Adopted {
686                            path: "/usr/local/bin/shep-log-rotate".to_string(),
687                            channel: false,
688                        }))
689                        .handshook(Some(false))
690                        .dog_stale(Some(false))
691                        .build(),
692                ])),
693            },
694            Reply {
695                id: 32,
696                result: Ok(Response::Flock(vec![
697                    ProcessInfo::builder(10, "log-rotate", ProcStatus::Online)
698                        .pid(Some(208_341))
699                        .dog(Some(DogSource::Adopted {
700                            path: "/usr/local/bin/shep-log-rotate".to_string(),
701                            channel: false,
702                        }))
703                        .handshook(Some(false))
704                        .dog_stale(Some(true))
705                        .build(),
706                ])),
707            },
708            // Three entries, one per shape a load produces: applied, pending,
709            // refused. `env` is a pending name on purpose: the name travels
710            // and the value never does.
711            Reply {
712                id: 32,
713                result: Ok(Response::Applied(vec![
714                    SheepApplied::new("web", vec!["max_memory".to_string()], Vec::new(), None),
715                    SheepApplied::new(
716                        "api",
717                        Vec::new(),
718                        vec!["args".to_string(), "env".to_string()],
719                        None,
720                    ),
721                    SheepApplied::new(
722                        "worker",
723                        Vec::new(),
724                        Vec::new(),
725                        Some("worker is not registered".to_string()),
726                    ),
727                ])),
728            },
729            // `Added`'s tag, all a fixture can prove for a `Vec<ProcessInfo>`
730            // variant. Down here because every id in this vector is
731            // hand-written.
732            Reply {
733                id: 33,
734                result: Ok(Response::Added(vec![])),
735            },
736            // The config pane's answer, and the row that proves its whole
737            // security property: `env` serializes as an empty object while
738            // `env_keys` names the key beside it, so an out-of-tree reader
739            // learns here that a value never travels (IR-41).
740            Reply {
741                id: 34,
742                result: Ok(Response::SheepConfig(Box::new(SheepConfigView::new(
743                    {
744                        let mut config = AppConfig::minimal("web", "./srv");
745                        config
746                            .env
747                            .insert("DATABASE_URL".to_string(), "postgres://x".to_string());
748                        config
749                    },
750                    vec!["max_restarts".to_string()],
751                    vec!["env".to_string()],
752                )))),
753            },
754            // The three acknowledgements. None echoes what was written:
755            // `SheepEnvSet` names the key and not its value, for the reason
756            // the row above pins, `SheepFieldSet` does the same and adds
757            // the one bit the caller cannot derive, and `DogConfigSet`
758            // names the dog and not the section.
759            Reply {
760                id: 35,
761                result: Ok(Response::SheepEnvSet {
762                    name: "web".to_string(),
763                    key: "DATABASE_URL".to_string(),
764                }),
765            },
766            // `pending` pinned `true`, because `false` is the value a reader
767            // that dropped the field entirely would decode by accident, and
768            // the two answers send an operator to different places: one
769            // says the change is in force, the other says to reload.
770            Reply {
771                id: 36,
772                result: Ok(Response::SheepFieldSet {
773                    name: "web".to_string(),
774                    key: "script".to_string(),
775                    pending: true,
776                    // `None` on purpose: this is the row every earlier
777                    // version's fixture already pinned, and `warning` must
778                    // stay invisible on the wire for that value or the
779                    // additive claim above is false.
780                    warning: None,
781                }),
782            },
783            Reply {
784                id: 37,
785                result: Ok(Response::DogConfigSet {
786                    name: "bark".to_string(),
787                }),
788            },
789            // A count and not the entries: a dog already knows what it
790            // pushed, and echoing the map back would put every value it
791            // sent on the wire a second time for no reader (IR-41).
792            Reply {
793                id: 38,
794                result: Ok(Response::SecretsPut { accepted: 2 }),
795            },
796            // Key names only, on all three lists, and none empty here on
797            // purpose: an empty `Vec` serializes the same whether it holds
798            // strings or something else, so a reader that guessed the
799            // element type wrong would still pass against an empty-list
800            // fixture. `collisions` also proves a forced write can name a
801            // key in both `set` and `collisions` at once.
802            Reply {
803                id: 39,
804                result: Ok(Response::SheepEnvBatch {
805                    name: "web".to_string(),
806                    set: vec!["DATABASE_URL".to_string(), "API_KEY".to_string()],
807                    unchanged: vec!["LOG_LEVEL".to_string()],
808                    collisions: vec!["API_KEY".to_string()],
809                }),
810            },
811            // Both halves of the same reply: a machine that has been read
812            // and one that has not been read for long enough yet. The rates
813            // are what separate them, and they are `null` rather than `0`
814            // in the second, which is the whole distinction the renderer
815            // spells as `-`.
816            Reply {
817                id: 40,
818                result: Ok(Response::HostUsage(Some(HostUsage {
819                    cpu_percent: Some(11.5),
820                    memory_used_bytes: 39_963_869_184,
821                    memory_total_bytes: 51_539_607_552,
822                    disk_bytes_per_second: Some((1_258_291, 491_520)),
823                    network_bytes_per_second: Some((24_594, 9_260)),
824                }))),
825            },
826            Reply {
827                id: 41,
828                result: Ok(Response::HostUsage(Some(HostUsage {
829                    cpu_percent: None,
830                    memory_used_bytes: 39_963_869_184,
831                    memory_total_bytes: 51_539_607_552,
832                    disk_bytes_per_second: None,
833                    network_bytes_per_second: None,
834                }))),
835            },
836            // A platform `sysinfo` cannot read at all, which is neither of
837            // the two above.
838            Reply {
839                id: 42,
840                result: Ok(Response::HostUsage(None)),
841            },
842            // Two sheep, one table each, so this row proves a dog reads
843            // more than one sheep's table from a single answer. The table
844            // itself repeats `SetSheepDogSettings`'s own two keys, one
845            // nested, so both rides through this enum agree on the shape.
846            Reply {
847                id: 43,
848                result: Ok(Response::DogSheepSettings {
849                    tables: BTreeMap::from([
850                        (
851                            "web".to_string(),
852                            DogTable::from(serde_json::Map::from_iter([
853                                ("concurrency".to_string(), serde_json::json!(2)),
854                                (
855                                    "hours".to_string(),
856                                    serde_json::json!({ "start": "09:00:00" }),
857                                ),
858                            ])),
859                        ),
860                        (
861                            "worker".to_string(),
862                            DogTable::from(serde_json::Map::from_iter([(
863                                "concurrency".to_string(),
864                                serde_json::json!(1),
865                            )])),
866                        ),
867                    ]),
868                }),
869            },
870            Reply {
871                id: 44,
872                result: Ok(Response::SheepDogSettingsSet {
873                    name: "web".to_string(),
874                    dog: "jobs".to_string(),
875                }),
876            },
877        ];
878        insta::assert_json_snapshot!("reply_wire_v11", replies);
879    }
880
881    /// The additive claim on `SheepFieldSet::warning` has three halves and
882    /// the reply fixture pins only the first. `None` staying off the wire is
883    /// what makes the field additive at all; `Some` surviving a round trip is
884    /// what makes it useful; and a payload with no `warning` key reading back
885    /// as `None` is what lets a daemon built before the field answer a client
886    /// built after it.
887    ///
888    /// The third pins the behaviour, not the attribute. Measured by removing
889    /// `#[serde(default)]` and re-running: it still passes, because serde
890    /// already reads a missing `Option` field as `None` without being asked.
891    /// So the attribute is belt-and-braces and this test would not notice its
892    /// removal. What it does notice is the field being renamed, retyped, or
893    /// made required, which is what would actually break an older peer.
894    #[test]
895    fn the_set_field_warning_is_additive_in_both_directions() {
896        let quiet = Response::SheepFieldSet {
897            name: "web".to_string(),
898            key: "script".to_string(),
899            pending: true,
900            warning: None,
901        };
902        let json = serde_json::to_string(&quiet).unwrap();
903        assert!(
904            !json.contains("warning"),
905            "a `None` warning must not reach the wire at all: {json}"
906        );
907
908        let loud = Response::SheepFieldSet {
909            name: "web".to_string(),
910            key: "cwd".to_string(),
911            pending: true,
912            warning: Some("/srv/app does not exist yet".to_string()),
913        };
914        let json = serde_json::to_string(&loud).unwrap();
915        let back: Response = serde_json::from_str(&json).unwrap();
916        assert_eq!(back, loud, "a warning must survive the round trip: {json}");
917
918        let older =
919            r#"{"kind":"sheep_field_set","data":{"name":"web","key":"script","pending":true}}"#;
920        let back: Response = serde_json::from_str(older).unwrap();
921        assert_eq!(
922            back,
923            Response::SheepFieldSet {
924                name: "web".to_string(),
925                key: "script".to_string(),
926                pending: true,
927                warning: None,
928            },
929            "a peer that predates the field must still deserialize"
930        );
931    }
932
933    #[test]
934    fn save_roll_serializes_snake_case_with_its_payload_under_data() {
935        assert_eq!(
936            serde_json::to_string(&Request::SaveRoll).unwrap(),
937            r#"{"kind":"save_roll"}"#
938        );
939        let reply = Response::RollSaved {
940            path: "/tmp/flock.json".to_string(),
941            apps: 3,
942        };
943        let wire = r#"{"kind":"roll_saved","data":{"path":"/tmp/flock.json","apps":3}}"#;
944        assert_eq!(serde_json::to_string(&reply).unwrap(), wire);
945        assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), reply);
946    }
947
948    /// The listing is empty on purpose: `reply_wire_snapshots` pins the row
949    /// field by field.
950    #[test]
951    fn muster_serializes_snake_case_with_its_listing_under_data() {
952        assert_eq!(
953            serde_json::to_string(&Request::Muster).unwrap(),
954            r#"{"kind":"muster"}"#
955        );
956        let reply = Response::Mustered(Vec::new());
957        let wire = r#"{"kind":"mustered","data":[]}"#;
958        assert_eq!(serde_json::to_string(&reply).unwrap(), wire);
959        assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), reply);
960    }
961}