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}