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}